一、一条静态资源请求要过六道关
防盗链这个词容易让人以为它是一道开关:开了就防盗,关了就没事。真实情况是,一个图片请求从被发现到被浏览器画出,中间要经过六道可以拦它的关:页面上引用它的方式、浏览器的发起策略、CDN 的边缘规则、源站 Nginx 的 referer 校验、应用层的签名校验、以及最终的日志与监控。你只配其中一道,就只能在那一层有效,其他层照样漏。
先明确要防的是什么。盗链通常是三种行为之一:别的站直接把你的图片 URL 写进自己的页面,消耗你的带宽;采集站点批量拉取你的全站资源;或者有人把你的图片嵌进邮件、论坛签名里。这三种的 referer 表现完全不同:第一种带别人的域名,第二种常常是空 referer,第三种可能是邮件客户端的奇怪来源甚至完全不发 referer。
所以配置顺序应该是先决定「允许谁」,再决定「拒绝时给什么」,最后才是「怎么监控」。反过来做,一定会把正常用户拦掉。
二、referer 校验:先看清楚它到底传了什么
Nginx 的 valid_referers 是最常被抄的一段配置,也是最常配错的一段。它有几个反直觉的语义,不弄清楚就会「配了跟没配一样」。
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
valid_referers none blocked server_names
*.example.com example.com
~\.google\. ~\.baidu\. ~\.bing\. ~\.so\.;
if ($invalid_referer) {
return 403;
}
}
# 命中情况先打到日志里看一周,再决定拦不拦
log_format refcheck '$remote_addr "$request" "$http_referer" inv=$invalid_referer';
access_log /var/log/nginx/refcheck.log refcheck;
三个关键字必须记牢:none 表示「完全没有 referer 头」,blocked 表示「有 referer 头但内容被删改或不符合规范」(比如 https 页面引用 http 资源时浏览器会发一个只剩域名的 referer),server_names 表示本 server 块里 server_name 声明的所有名字。少了 blocked 会导致 https 站引用自己图片时有一批被拦,这是最常见的「自己站图片裂了」的原因。
~ 开头的是正则,会匹配 referer 里的主机部分。写成 ~\.google\. 是为了匹配所有 Google 地区域名,末尾那个点很重要,否则 google.cn.evil.com 这类域名也会通过——referer 校验的绕过方式之一就是构造一个包含你信任域名的子串。更严格的做法是写完整的锚定正则:
# 更严格:锚定到主机名结尾,避免子串匹配被绕过
valid_referers none blocked
~^https?://([a-z0-9-]+\.)*example\.com(:[0-9]+)?/
~^https?://([a-z0-9-]+\.)*(google|baidu|bing|so|sogou|yandex)\.[a-z.]+/;
# 目录级白名单:只对 /uploads 生效,不影响 CSS/JS
location ^~ /uploads/ {
valid_referers none blocked server_names
~^https?://([a-z0-9-]+\.)*example\.com/;
if ($invalid_referer) { return 403; }
expires 30d;
add_header Cache-Control "public";
}
要注意的一个陷阱:if 在 location 里搭配 return 是安全的(这是 Nginx 官方认可的少数安全用法之一),但不要在里面写 rewrite ... break 之类会打断执行流程的指令。另外 if 块内的 add_header 会导致外层的 add_header 全部失效——这是继承规则导致的,很多人配完防盗链发现缓存头没了,原因就在这里,应该把 expires 放在外面或者干脆用 map 实现。
# 用 map 代替 if,避免 add_header 丢失,也更好维护
map $http_referer $deny_referer {
default 0;
"" 0;
"~^https?://([a-z0-9-]+\.)*example\.com/" 0;
"~^https?://([a-z0-9-]+\.)*(google|baidu|bing|so|sogou)\." 0;
"~." 1;
}
server {
location ^~ /uploads/ {
if ($deny_referer) { return 403; }
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
}
这段 map 的语义是「默认放行,只对明确匹配到外部域名的情况拒绝」,比白名单更宽松,适合不确定站内引用会不会被遗漏的场景。两种写法没有绝对优劣,白名单更安全、更容易误伤;黑名单(反向逻辑)更宽松、更容易漏。刚开始建议先用日志观察一周 $invalid_referer 的分布,再决定用哪种。
三、空 referer 是最大的灰色地带
很多人配白名单时把 none 去掉,结果发现:微信内置浏览器里图片裂了、某些 Android 浏览器裂了、从 https 站跳转来的没了、装了隐私插件的用户全裂了。原因是隐私策略越来越普遍地把 referer 剥离成空值,而空 referer 既可能是盗链(curl、采集器),也可能是正常用户。
这个矛盾没有完美解法,只有取舍。三条可用的判断:
第一,如果你要防的是「别人站引用你的图」,那空 referer 不是威胁——盗链者为了自己的页面能显示,一定会带 referer。此时可以放心地允许 none。
第二,如果你要防的是「采集器批量拉图」,那空 referer 就是主要威胁。但这类请求还有别的特征:User-Agent 是 curl / python-requests / 空的 UA、请求频率高、Accept 头缺失或怪异、同一个 IP 短时间内拉完整个目录。用这些特征去拦,比用 referer 精确得多,也不会误伤用户。
# 针对批量采集的特征拦截,比 referer 更准
map $http_user_agent $bad_ua {
default 0;
"" 1;
"~*(curl|wget|python-requests|scrapy|java/|go-http-client)" 1;
}
# 按 IP 限速兜底:单 IP 每分钟 120 个图片请求
limit_req_zone $binary_remote_addr zone=imgzone:10m rate=120r/m;
location ^~ /uploads/ {
if ($bad_ua) { return 403; }
limit_req zone=imgzone burst=60 nodelay;
limit_req_status 429;
}
返回 403 会让采集器立刻知道被拦、可能换 IP;返回 429 明确表达「限速」,对正规客户端友好,同时给监控一个干净的信号。千万别返回 302 跳转到一张「禁止盗链」的图片——那等于给盗链站送了一张合法的图,还多消耗一次请求。
第三,如果你两者都要防,正确做法是分层:对「已知的采集器特征」直接拒,对「带外部 referer」直接拒,对「空 referer 且 UA 正常」放行。这三条一起写,误伤率极低。
四、签名 URL:真正能挡住转发的一层
referer 校验有个根本弱点:它依赖客户端自觉发送头,curl -H "Referer: https://example.com/" 一行就绕过了。如果你的资源是真有价值的(付费内容、原图、视频),就需要应用层签名。
签名的核心是「URL 带一个只有服务端能算出来的参数,并且有有效期」。最简单的形式是 expires 加 sign,sign 由路径、expires 和密钥用 HMAC 算出。
# Nginx 侧验证(需要 secure_link 模块,官方包已内置)
location ^~ /private/ {
secure_link $arg_sign,$arg_expires;
secure_link_md5 "$secure_link_expires$uri$remote_addr secret-key";
if ($secure_link = "") { return 403; } # 签名不匹配
if ($secure_link = "0") { return 410; } # 已过期
}
# 生成端(Python),注意参数顺序必须与 nginx 侧完全一致
import base64, hashlib, time
key = b"secret-key"
uri = "/private/2026/11/photo.jpg"
exp = int(time.time()) + 600
raw = f"{exp}{uri}127.0.0.1{key.decode()}".encode()
digest = base64.urlsafe_b64encode(hashlib.md5(raw).digest()).decode().rstrip("=")
print(f"https://cdn.example.com{uri}?sign={digest}&expires={exp}")
签名方案的三个硬性要求:密钥不能出现在任何前端代码里(否则等于没签)、过期时间要留出 CDN 回源的余量(太快会导致 CDN 缓存了但回源被拒)、$remote_addr 如果参与签名,就要考虑 CDN 回源时这个值变成了 CDN 节点的 IP 而不是用户 IP。最后这一点是签名方案在 CDN 场景下最常见的故障,正确做法是用 real_ip 模块把真实 IP 还原出来再参与计算。
# CDN 回源时的真实 IP 还原,否则签名校验必然失败
set_real_ip_from 203.0.113.0/24; # CDN 回源节点段
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 验证 real_ip 是否生效:日志里看 $remote_addr 是否是用户公网 IP
log_format ipcheck '$remote_addr | $http_x_forwarded_for | $request';
如果资源本身公开(文章配图),签名方案的运维成本明显高于收益,此时 referer 加 UA 特征就够了。签名的价值在于「资源本身需要授权」,而不是「资源公开但不想被别人引用」。
五、CORS 和防盗链不是一回事,别混着配
CORS 管的是「浏览器允许不允许 JS 读取响应内容」,防盗链管的是「允许不允许这个请求被发出去并拿到资源」。两者作用对象不同,但实践中经常同时出现,配置互相干扰。
典型的混杂场景是字体文件。@font-face 加载跨域字体时浏览器强制要求 CORS 头,否则直接拒绝使用。如果你给字体加了防盗链但没加 CORS 头,结果是跨域的合法引用也用不了,日志里看到的是正常请求但页面就是不显示字体。
# 字体与图片分目录,策略各管各的
location ^~ /assets/fonts/ {
add_header Access-Control-Allow-Origin "https://www.example.com" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Vary Origin always;
if ($request_method = OPTIONS) { return 204; }
expires 365d;
}
# 图片不开放 CORS,但允许 canvas 读像素的场景需要单独开
location ^~ /uploads/ {
# 默认不发 CORS 头:跨域 img 标签仍能显示,但 canvas 无法读像素
expires 30d;
}
三个要记住的点:Access-Control-Allow-Origin 不能写多个域名,也不能写 * 还想带 credentials;always 参数确保 4xx/5xx 响应也带上头,否则前端只会看到一个没有信息的网络错误;Vary: Origin 必须有,否则 CDN 或浏览器缓存可能把 A 站的响应给 B 站用。
还有一个隐蔽的问题:如果你用 map 动态回显 $http_origin 到 Access-Control-Allow-Origin,一定要在 map 里用白名单校验来源,不能直接回显——直接把任意来源回显等于对所有站点开放了跨域读取。
map $http_origin $cors_ok {
default "";
"https://www.example.com" $http_origin;
"https://example.com" $http_origin;
"~^https://([a-z0-9-]+\.)*example\.com$" $http_origin;
}
location ^~ /assets/ {
add_header Access-Control-Allow-Origin $cors_ok always;
add_header Vary Origin always;
}
六、这道关到底值不值得:算一次带宽账
防盗链要消耗你的时间、增加配置复杂度、还可能误伤用户。所以上线前应该先算一次账,判断它是否值得。
算法很直接:从访问日志里统计图片请求的来源分布,看外部 referer 与空 referer 各占多少百分比、各消耗多少字节。如果外部来源占图片总流量不到 5%,那防盗链带来的收益很小,配一个宽松的 UA 特征拦截就够了;如果超过 30%,那说明你的图确实在被大量盗用,值得认真做。
# 按 referer 分组统计图片流量(假设日志里有 $body_bytes_sent)
$ awk '$7 ~ /\.(jpg|png|gif|webp)$/ {
ref = $11;
if (ref == "-") { k = "empty"; }
else if (ref ~ /example\.com/) { k = "self"; }
else { k = ref; }
bytes[k] += $10; cnt[k]++;
} END { for (k in bytes) printf "%-40s %10d req %12d MB\n", k, cnt[k], bytes[k]/1048576 }
' /var/log/nginx/access.log | sort -k5 -rn | head -20
这个统计能同时回答三个问题:谁在盗、盗了多少、值不值得为它花时间。大多数个人站的结论是「外部 referer 占 2%~8%,主要是搜索引擎的图片搜索和一些聚合站」,这个量级不值得做严格的防盗链,但值得做 UA 特征拦截来挡住采集器。
还有一个反向的考虑:搜索引擎的图片搜索会给你带来流量。google.com、bing.com、image.baidu.com 这些来源的 referer 一定在白名单里,否则你会主动掐掉一部分入口流量。这一点在配白名单时特别容易漏,尤其是只加了 *.example.com 就上线的情况。
七、上线顺序与验证清单
这套配置的正确上线顺序是四步,顺序错了会给自己制造事故。
第一步,只记录不拦截。把 $invalid_referer 打到一个独立日志里跑三天,用人眼看看都会匹配到什么。这一步几乎零风险,能发现所有你想不到的来源。
第二步,只对采集器生效。map $http_user_agent 挡 curl、空 UA、明显异常的客户端,同时把限速开上。这一步会减少大量无效流量,且几乎不可能误伤。
第三步,对图片目录开启 referer 校验,但用 return 429 而不是 403,并且保留 none 与 blocked。观察一天,看正常用户有没有受影响。
第四步,把明确的外部域名来源改成拒绝,并考虑对高价值资源上签名 URL。
# 上线后的验证命令,五条一次跑完
$ curl -sI -H 'Referer: https://evil.example.org/' https://www.example.com/uploads/test.jpg | head -1
# 期望 HTTP/1.1 403 或 429
$ curl -sI https://www.example.com/uploads/test.jpg | head -1
# 期望 200:空 referer 应当放行
$ curl -sI -H 'Referer: https://www.example.com/post/1' https://www.example.com/uploads/test.jpg | head -1
# 期望 200:本站引用必须通
$ curl -sI -H 'Referer: https://www.google.com/' https://www.example.com/uploads/test.jpg | head -1
# 期望 200:搜索引擎不在白名单之外
$ curl -sI -H 'User-Agent: curl/7.88' https://www.example.com/uploads/test.jpg | head -1
# 期望 403:采集器特征被拦
这五条命令应该在每次改完配置后都跑一遍,并且写进变更记录。它们的价值在于把「配了」变成「验证过」——这两件事在事故复盘里差别巨大。
最后补充一个长期维护的点:白名单里的域名会过期,CDN 的回源 IP 段会变,搜索引擎会加新的地区域名。所以这条配置不该配完就忘,而应该放进季度核对清单,和证书、DNS、备份一起过一遍。防盗链真正的问题从来不是「写不出来」,而是「写了三个月之后没人记得它存在,直到某天正常用户被拦了才发现」。