图片防盗链配错:Referer白名单与CDN回源

一批图片失效,外链却全是好的

上半年我做了一次图片防盗链。做法很常见:在 Nginx 里给 uploads 目录加了 Referer 白名单,只允许本站域名访问,其余一律返回 403。上传之后我自己用浏览器看了几张,都正常,于是关掉终端去干别的了。

两天后我收到一封读者邮件,说文章里的图全挂了。我当时第一反应是「不可能」,因为我这边看是好的。打开无痕窗口,换一个域名访问,图确实是坏的。更奇怪的是:直接复制图片链接到地址栏打开,图是正常的。

这就是防盗链这一类配置最典型的两种翻车方式。第一种是规则写宽了,盗链没挡住;第二种是规则写窄了,把不该挡的全部挡掉了。前者是损失流量,后者是损失可信度,而后者往往要两三天才会有人告诉你。这篇文章讲第二种,以及背后那套「为什么浏览器能看、爬虫看不到」的机制。

先把三个请求分清楚

判断防盗链是否正常,核心是把三类请求分开看:

第一类,页内引用。浏览器解析 HTML 里的 img 标签,向图片地址发起请求。此时浏览器会自动带上 Referer,值是引用页的 URL。

第二类,直接访问。用户在地址栏里粘贴图片链接,或者在书签里打开。这类请求通常不带 Referer,或者带的是当前页自身。

第三类,程序请求。curl、wget、各种抓取工具、RSS 阅读器、CDN 回源。这类默认完全不带 Referer。

我的规则当时写的是「Referer 必须匹配本站域名」,把第二类和第三类全部拦掉了。而在浏览器里做测试,走的都是第一类,所以看起来一切正常。

这里有一个更微妙的点:从 http 页面跳转到 https 页面时,浏览器默认不发送 Referer。如果你的站点有混合协议的历史链接,那么从 http 入口进来的访客,页内引用的图片请求全都不带 Referer,也就全被拦了。这类访客占比不大,但正好落在「自己测不出来」的范围内。

Referer 不是一个可信的字段

写白名单之前最好接受一个事实:Referer 是客户端自愿发送的一个请求头,服务端完全无法验证它的真实性。任何人用一个参数就能伪造。所以防盗链本身不是安全措施,它只是抬高盗链成本的措施。

理解这一点之后,配置思路会变。原来的思路是「不是本站的一律拒」,现在的思路应该是「明确是外站的才拒,不确定的放行」。因为放行一个盗链的成本是带宽,拒掉一个正常访客的成本是文章不可读。

这是防盗链最重要的一条设计原则,也是我那次翻车的直接原因。

Nginx 的写法:先写好「放行」再写「拒绝」

最基础的配置长这样,关键字是 valid_referers:

location ~* ^/uploads/.*\.(jpg|jpeg|png|gif|webp|avif)$ {
    valid_referers none blocked server_names
                   *.zz1984.com zz1984.com
                   ~\.google\. ~\.baidu\. ~\.bing\. ~\.sogou\.;

    if ($invalid_referer) {
        return 403;
    }

    expires 30d;
    add_header Cache-Control "public";
}

关键在于 valid_referers 后面那几类参数的含义,很多人只写了域名,把前两类漏掉了:

none 表示没有 Referer 的请求算合法。这就是「直接访问」和「程序请求」,加上它,图床、RSS、书签全部恢复正常。

blocked 表示 Referer 存在但被隐私设置剥离的请求算合法。这类请求的 Referer 值是空的或者被代理改写成不含协议的形式,浏览器隐私模式、某些企业网关、以及从 https 到 http 的降级跳转都会产生这种值。不加 blocked,会有一小部分真实用户看不到图,而且极难复现。

server_names 表示当前 server 块里 server_name 列出的域名算合法。有了它就不必重复手写自己的域名,多站点共用一份配置时也不容易漏。

域名通配写法 *.zz1984.com 匹配所有子域名,但不匹配主域本身,所以主域要单独写一次。这一点很容易漏,漏了之后主域访问图片全是 403,而 www 子域正常,现象非常古怪。

最后那行以波浪线开头的是正则,用于放行搜索引擎的图片搜索。如果你的图能出现在图片搜索里,这部分流量不该被自己掐掉。注意正则里的点要转义,不转义会匹配到 google.com.evil.example 这种域名。

更严重的坑:CDN 把 Referer 改掉了

如果站点前面有 CDN,问题会复杂一层。CDN 回源时可能不转发 Referer,也可能转发成 CDN 自己的域名。这种情况下,源站的防盗链规则会把所有经过 CDN 的图片请求判为盗链,于是整站图片全挂,但直接访问源站 IP 又是好的。

这个现象的判据很清晰:把它当成一次「层级归因」问题,而不是配置问题。按下面的顺序验证:

# 1. 直接打源站,绕过 CDN
curl -sI -H "Host: www.zz1984.com" \
  "http://127.0.0.1/uploads/2026/05/cover.jpg" | head -5

# 2. 打 CDN,看回源头
curl -sI "https://www.zz1984.com/uploads/2026/05/cover.jpg" | head -5

# 3. 带 Referer 打 CDN
curl -sI -e "https://www.zz1984.com/1837.html" \
  "https://www.zz1984.com/uploads/2026/05/cover.jpg" | head -5

如果第一步 200、第二步 403,那么问题在源站的规则与 CDN 的转发方式之间。解决方向有两个:一是让 CDN 回源时带上原始 Referer(大部分 CDN 的设置里有这个开关),二是在源站把 CDN 节点的回源请求识别出来并放行。

后者的实现方式是匹配回源 IP 段:

geo $is_cdn {
    default 0;
    203.0.113.0/24 1;
    198.51.100.0/24 1;
}

server {
    ...
    location ~* ^/uploads/ {
        set $allow 0;
        if ($is_cdn) { set $allow 1; }
        if ($invalid_referer) { set $allow 0; }
        ...
    }
}

不过更简单的做法是用 geo 配合 map 一次性算出一个结果,避免在 if 里堆嵌套。Nginx 的 if 在 location 里做赋值是安全的,但多个 if 叠加时执行顺序容易让人误判,配置越长越危险。

还有一个常被忽略的点:CDN 的缓存键。如果防盗链规则返回 403,而这个 403 被 CDN 缓存了,那么即使你后来改好规则,用户看到的还是缓存里的 403。给 403 加上 no-store 是必需的,否则一次配错会在 CDN 上留很久。

Hotlink 与热链图片:白名单之外的两条路

有时候你并不想拒绝盗链,而是想「换成广告图」。这是另一种做法,通常叫盗链替换:

location ~* ^/uploads/.*\.(jpg|jpeg|png)$ {
    valid_referers none blocked server_names *.zz1984.com;
    if ($invalid_referer) {
        rewrite ^ /static/hotlink.png break;
        return 200;
    }
    expires 30d;
}

这样盗链方看到的是一张写着站点名的小图,而你既没损失带宽(小图很小),还多了一次曝光。代价是实现稍复杂,而且 rewrite 与 return 的组合在某些版本里的行为容易让人困惑,建议先在测试机上验证。

我个人的选择是不做替换,只做「确定性拒绝」。原因是替换方案需要一张永远不换的兜底图,而兜底图本身会成为被吐槽的对象。宁可 403,也不要让盗链站点把你的图换成一张看不懂的占位图。

用 curl 做一次完整的自检

配置改完,不要用浏览器看几张就结束。用 curl 把四种请求都打一遍,输出状态码,一眼就能看出规则是否正确:

URL="https://www.zz1984.com/uploads/2026/05/cover.jpg"

echo -n "no referer      "
curl -s -o /dev/null -w "%{http_code}\n" "$URL"

echo -n "own referer     "
curl -s -o /dev/null -w "%{http_code}\n" -e "https://www.zz1984.com/1837.html" "$URL"

echo -n "external referer "
curl -s -o /dev/null -w "%{http_code}\n" -e "https://evil.example/page" "$URL"

echo -n "google referer  "
curl -s -o /dev/null -w "%{http_code}\n" -e "https://www.google.com/search?q=test" "$URL"

预期的结果是 200、200、403、200。四个都是 200 说明规则没生效(可能是 location 没匹配上),第一个是 403 说明漏了 none,第三个是 200 说明规则写宽了。

第四个请求经常被人忘记。图片搜索带来的流量对内容站不算大,但它属于「白送的自然流量」,掐掉没有道理。

为什么浏览器开发者工具会骗你

自查时还有一个陷阱:浏览器的 DevTools 里,图片请求可能显示为「from disk cache」,根本不发请求,于是你看不到 403。更麻烦的是,即使请求发了,DevTools 默认显示的也可能是上一次成功的响应。

正确做法是用无痕窗口,并且先清掉缓存,或者在 Network 面板勾选 Disable cache。更彻底的方法是换一个从未访问过该站点的浏览器,或者直接用 curl。我那次之所以两天后才发现,就是因为我一直开着同一个标签页,图片全在内存缓存里。

另外,img 标签加载失败时浏览器不会弹出任何提示。页面布局会把图撑成空白,某些主题甚至会把占位图渲染成纯色块,看起来像设计风格。这是「图片全挂但没人报错」的技术原因。

防盗链的另一面:它挡不住真正的盗链

值得说清楚的一点是:加了 Referer 白名单,主要挡住的是「直接在第三方网站里 img 引用你的图」这种低成本的盗链。对于真正想搬运的人,把图片下载下来重新上传到自己服务器,成本几乎一样低,而防盗链对此完全无效。

所以防盗链的定位应该是:治理邻近站点的顺手盗链、降低带宽被白嫖的概率,而不是「保护图片版权」。如果想真的控制图片被搬运,能做的事在图片本身上——加可见水印、只给低分辨率版本、或者干脆用带签名的临时链接。

带签名链接是 Cloudflare 之类的对象存储提供的功能:URL 里带一个过期时间与签名,任何人不带签名就拿不到文件。这确实有效,代价是页面 HTML 不能缓存(因为每次的签名不同),对个人站点来说通常不划算。

几条实践下来的边界

第一,白名单必须包含 none 与 blocked。这是「不确定的放行」原则的具体落地。缺了它们,问题不会立刻暴露,但会在两三天后的某个读者邮件里暴露。

第二,主域与子域要分开写。*.example.com 不包含 example.com,这个语法细节没有例外。

第三,403 必须带 no-store,并且在 CDN 上手动刷新一次。缓存的 403 是这一类事故里最容易被忽略的二次伤害。

第四,CDN 回源要带原始 Referer,或者在源站识别回源 IP 段放行。这两种方案选一种,不要都做,否则规则会互相打架,排查时无法判断是哪一层起的作用。

第五,检查时用 curl 的四种请求,不要用浏览器。浏览器缓存会让「明明是坏的」看起来是好的。

这件事告诉我的

防盗链是一个「不出事时几乎不存在的配置」,它在 99% 的情况下是对的,剩下 1% 会让全部图片一起消失,而你作为站长,恰恰是最不容易看到那 1% 的人。因为你的浏览器有缓存、你的 Referer 永远是本站、你的网络永远是同一台机器。

所以对这类配置,我现在的习惯是:改完之后写一条 curl 自检命令,存进运维笔记,以后每次动 Nginx 的 location 都重跑一遍。这套命令只花了三分钟写,但它能覆盖我肉眼永远看不到的那部分读者。

还有一条更朴素的体会:任何「默认拒绝」的规则,都要先问一句「如果这个规则判断错了,谁会受影响」。防盗链判断错了,受影响的是最安静的读者;限流判断错了,受影响的是最喜欢你内容的读者。这两种错误都不该由用户来替你发现。

Last modification:October 25th, 2026 at 12:24 pm

Leave a Comment