需要反防盗链的原因

文章同时在微信平台和Github Pages个人博客中发布,不想传两次图片的话,可以把微信平台当图床.

但是微信平台的图片有反盗链,不是在浏览微信文章的时候打开图片的话,无法显示.

防盗链的判断

判断请求图片时浏览器发送的referrer.

referrer是浏览器请求时自动带上的一个域名,表示是从哪个页面发起的请求.

如果referrer中带的链接是平台文章链接,则可以打开图片.

如果referrer链接是其它域名,则表示是其它网站引用的图片,会判断为盗链从而无法打开.

不过这次测试发现如果用盗链方式打开某一个链接打不开后,仅修改referrer的话,在一段时间内还是打不开,因此也可能有其它判断依据,但目前主要还是看referrer.

反防盗链的原理

有个浏览需求和防盗链有矛盾:在浏览器地址栏直接打开图片链接,需要能访问图片,而此时请求头不带referrer.

也就是说只要请求图片时候不带上referrer属性,后端会把请求识别为用户直接打开的图片链接(而不是处在某个页面中的请求),因此将其放行.

所以只要防盗链系统允许从地址栏敲链接直接浏览资源,而我们又能把页面中的图片请求时候带的referrer属性去除的话,就无法靠referrer属性防盗链.

以往的(2017年之前)反防盗链

旧的反防盗链方式,用js将图片嵌入iframe以达到不发referer的效果,之前本站使用的就是这个:https://github.com/jpgerek/referrer-killer

可能出现的问题:

  • 如果img不是一开始就包在iframe中的话,浏览器在没执行js前就加载,依然是带referer请求(可能是预加载的机制)
  • iframe中的格式得另外设置
  • 复制到微信H5编辑器时,带的iframe识别不出来

可能还有其它办法(比如https页面带http请求不发referer,未验证此法)反防盗链,但应该还是iframe更合适通用.

用HTML新属性(最早2017年)反防盗链

Chrome 51开始为img引入了referrerpolicy属性.

具体可参考https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-referrerpolicy

因此设置referrerpolicyno-referrersame-origin在反防盗链场景中不发送referrer.

此属性的支持情况参考:https://caniuse.com/mdn-html_elements_img_referrerpolicy

img_referrerpolicy_support

目前大部分浏览器均已支持此属性.ios safari根据这个表应该是不支持的,但实际测试了下却能使用,可能实际是支持的,也可能是因为ios safari的其它隐私保护机制导致的吧,未验证.

所以,为了反防盗链,要为img加上属性referrerpolicy并设置为no-referrer.

<img src="https://mmbiz.qpic.cn/mmbiz_png/2FribXdgnhQP26F01ibXeUDJStbpb9ibmO7CYZH7dd1T8ZZ4INXOAueCNHZNvACIwGJ98r75eapc6InHHG6xSAzWA/0?wx_fmt=png" alt="img_referrerpolicy_support" referrerpolicy="no-referrer">

怎么在生成的html中加上referrerpolicy="no-referrer"

尝试过程

思考了几种加上这个属性的方案:

  • 用js的逻辑来加
  • markdown里面加
  • 实现jekyll的插件加
  • 实现Liquid的filter或者tag加

但是查阅了下,Github Pages对所使用的插件有白名单限制https://pages.github.com/versions/,因此后两种方案基本没法实现了,实际上我看了也不知道怎么实现.

接着考虑使用js来加,将之前的img放iframe,改成img都加上referrerpolicy="no-referrer":

    window.onload = function() {
    var imgs = document.querySelectorAll('img');
    [].forEach.call(imgs, function(element, index, array) {
      element.setAttribute("referrerpolicy","no-referrer");
  });
}

不行,在网络请求中还是先看到了img请求带referer.

也许是时机不对?加上onload再试.

    function killimgref() {
      var imgs = document.querySelectorAll('img');
      [].forEach.call(imgs, function (element, index, array) {
        element.setAttribute("referrerpolicy", "no-referrer");
      });
    }
    window.onload = killimgref;
    document.onload = killimgref;

也还是不行,也许再找找其它事件能比请求img的时机更早.

但想到浏览器可能会有某种机制(预加载机制?)在接收到img的src时就去请求,那js再怎么改也就没用了.

要实现的效果更好更稳定的话,还是得从markdown生成的html入手.

搜了下,找到类似的:https://stackoverflow.com/questions/41455133/how-do-you-programmatically-apply-a-css-class-to-paragraphs-in-jekyll

jekyll使用kramdown从md生成html,因此参考kramdown也确实是这样的,缺点是inline-attributes不是标准的markdown的语法.

用kramdown的语法设置referrerpolicy

https://kramdown.gettalong.org/quickref.html#inline-attributes

很简单只要在图片后面加上{: referrerpolicy="no-referrer"}就行.

![img_referrerpolicy_support](https://mmbiz.qpic.cn/mmbiz_png/2FribXdgnhQP26F01ibXeUDJStbpb9ibmO7CYZH7dd1T8ZZ4INXOAueCNHZNvACIwGJ98r75eapc6InHHG6xSAzWA/0?wx_fmt=png){: referrerpolicy="no-referrer"}

如何自动设置referrerpolicy

在加图片的时候手工加上{: referrerpolicy="no-referrer"}也不是不行,但文章可能很久才会写一次,因此这种语法下次肯定忘了.最好是写小作文时候,我只需要参考标准的markdown语法,由程序来生成额外的属性.

思考可能的方案

既然是自动的,那就要写个脚本来加.

add-noreferer-attr.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import glob

path = f'./_posts'

files = [f for f in glob.glob(path + "**/*.md", recursive=True)]

for file in files:
    replacement = ""
    with open(file, "r") as mdfilein:
        for line in mdfilein:
            if line.startswith('![') and line.find(r'{: referrerpolicy="no-referrer"}') < 0 and line.find(r'http') > 0:
                if line.endswith('\r\n'):
                    ending = '\r\n'
                else:
                    ending = '\n'
                replacement = replacement + line.strip() + r'{: referrerpolicy="no-referrer"}' + ending
            else:
                replacement = replacement + line
        # print(mdfile.readline())       
    with open(file, "w") as mdfileout:
        mdfileout.write(replacement)

然后考虑在何处何时可以自动运行此脚本,比较容易想到有两种:

  • git hook
  • CI,如Github Actions

各有优缺点,git hook比较通用,ci的话则是根据CI平台的不同配置的语法不同.

但是git hook似乎是本地跑的脚本才行,而如果是github.dev上直接编辑的是否可以呢?

应该不行(未验证),因为如果连github.dev的编辑也可以跑git hook的脚本的话,等于可以用上github的算力做事情了,而这应该是不允许的.

所以为了以后直接在github.dev在线写文章脚本也可以直接运行,应该还是用CI比较合适,看了下免费的配额(每月2000分钟)也足够使用了,并且这边本来就是使用的Github Pages也是绑定在这个平台上了,也就无所谓多使用一个Github Actions了.实际上,Github Pages的生成发布过程,也是CI/CD.

细看了下Github Actions的文档,可以指定CI过程所挂钩的branch,而Github Pages也可以指定所发布的branch.

这样就可以区分编辑和发布所使用的branch,使用不同分支也避免了Github Pages发布流程多次运行:人类提交一次,CI触发脚本修改了又提交一次,因此只用一个分支的话就会导致Pages运行两次发布.

区分master和publish后整个发布过程比较清晰:

  1. 作者在master分支写文章提交
  2. master提交触发Github Actions
  3. Github Actions运行脚本将批量添加后的md文件提交到publish分支
  4. publish分支的提交触发Github Pages的发布

配置Github Actions跑add-noreferer-attr.py

这里的逻辑比较简单,根据 https://docs.github.com/en/actions/quickstart 改动下入门脚本就行:

name: GitHub Actions Demo
on:
  push:
    branches:
      - master
jobs:
  Explore-GitHub-Actions:
    runs-on: ubuntu-latest
    steps:
      - run: echo "🎉 The job was automatically triggered by a $ event."
      - run: echo "🐧 This job is now running on a $ server hosted by GitHub!"
      - run: echo "🔎 The name of your branch is $ and your repository is $."
      - name: Check out repository code
        uses: actions/checkout@v2
      - run: |
          git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/*
          git config user.email "GitHubActions@github.com"
          git config user.name "GitHub Actions"
      - run: echo "💡 The $ repository has been cloned to the runner."
      - run: echo "🖥️ The workflow is now ready to test your code on the runner."
      - name: run  add-noreferer-attr.py
        run: |
          cd $
          chmod +x  add-noreferer-attr.py
          ./add-noreferer-attr.py
      - run: |
          git add -A
          if [ -z "$(git status --porcelain)" ]; then
            echo "No changes to commit"
          else
            git commit -m "auto add noreferer to publish in actions"
          fi
      - run: git push --force origin master:publish
      - run: echo "🍏 This job's status is $."

更简单的实现

在写这篇文章的时候看到MDN的文档才发现可以直接在全局的模板html文件中加上 <meta name="referrer" content="no-referrer" /> ,并且是这个项目中原本就有配置…但为啥没生效呢..?????

仔细看了下…才知道引号用错了…翻了下commit记录,发现在2019年的时候尝试使用no-referer来反盗链没成功才用的referer-killer的…但其实不是no-referer不行而是我加了引号不行…

quote error

看了下那天的谷歌搜索记录,发现今天又一次知道的东西之前就搜过了…然而现在才知道前年用no-referer不行的原因居然是因为引号太低级了,linter真的是很重要…

search history

那天还带着错误的引号查为啥no-referer没生效…

总结

写了很多才知道同一个坑被我踩了两次才过去,但这次毕竟是第一次使用Github Actions,而以后说不定有其它更适合的场景可以使用,比如用linter规范项目避免符号错误的情况再次出现,还是有参考价值的.

参考

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Referer

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-referrerpolicy

旧的反防盗链方式,将图片嵌入iframe以达到不发referer的效果:https://github.com/jpgerek/referrer-killer

iframe反防盗链参考:https://happy123.me/blog/2017/10/31/http-referer-de-dao-lian-yu-fan-dao-lian/

https://stackoverflow.com/questions/41455133/how-do-you-programmatically-apply-a-css-class-to-paragraphs-in-jekyll

https://kramdown.gettalong.org/quickref.html#inline-attributes

https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onpushpull_requestbranchestags