Nginx 防盗链配了却被绕过:valid_referers 的三个静默失效点与 $invalid_referer 实战判读

Nginx 防盗链配了却被绕过:valid_referers 的三个静默失效点与 $invalid_referer 实战判读

防盗链这件事看起来是最简单的 Nginx 配置之一,网上一搜全是三行代码:判断 $invalid_referer,返回 403。但真正上线之后你会发现两种相反的故障:一种是别人照样把你的图片挂在自己站上,流量还是被偷跑;另一种更糟,自己的站里图片大片变 403 破图,用户能看到、你能看到的地方都正常,偏偏某个页面全挂。这篇文章就把 valid_referers 这个指令的判读逻辑拆开讲清楚,把三个最容易静默失效的点找出来。

先看最基础的配置和它的真实语义

location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
    valid_referers none blocked server_names
                   *.example.com example.com
                   ~\.google\. ~\.baidu\. ~\.bing\.;
    if ($invalid_referer) {
        return 403;
    }
}

这段配置的判读顺序很多人搞错了。valid_referers 不是「白名单命中就放行」,而是「只要有任意一条规则匹配,$invalid_referer 就为空字符串(假),否则会被设为 1(真)」。所以 $invalid_referer 是一个布尔标记,判断它为空即放行。理解这一点很重要,因为它决定了「多加一条规则」的效果往往是「放宽」而不是「收紧」。

规则里几个关键字的确切含义:none 表示请求头里完全没有 Referer——注意这是在 HTTPS 页面引用 HTTP 资源、或者用户从浏览器地址栏直接打开图片、或者某些客户端主动不带 Referer 的情况;blocked 表示有 Referer 头但值是空的或者被防火墙/代理改成了不合法形式(比如被脱敏成 -----);server_names 会自动把当前 server 块里所有 server_name 加进白名单;example.com 这类裸域名只精确匹配该主机名,*.example.com 匹配所有子域但不匹配裸域本身;带 ~ 前缀的按正则匹配,注意正则里 . 要转义,写成 ~\.google\. 才能匹配 https://www.google.com/,写成 ~.google. 会连 xgoogley 这种都放行。

失效点一:CDN 把 Referer 洗掉了,你的 none 规则误放行

这是最普遍的问题。站点套了 Cloudflare 之类的 CDN 之后,源站看到的 Referer 可能已经不是访客真实的那一个。更麻烦的是,盗链者如果直接请求你的图片 URL,而且他的页面本身是通过某种方式让浏览器不发 Referer(比如 referrerpolicy="no-referrer",或者用了 <meta name="referrer" content="no-referrer">),那这个请求打过来就是完全的 none,而你的配置里写了 none,于是被放行。

怎么确认这件事真的发生了?不要靠猜,去日志里看真实的 Referer 分布。前提是在 log_format 里把 $http_referer 记下来了:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" "$http_user_agent"';

# 统计图片请求里 Referer 为空的占比
awk '$7 ~ /\.(jpg|png|gif|webp)$/ && $11 == "\"-\"" {n++} $7 ~ /\.(jpg|png|gif|webp)$/ {t++} END {print "empty:",n," total:",t, "ratio:", n/t}' \
    /var/log/nginx/access.log

# 按 Referer 域名聚合,看谁在盗链
awk '$7 ~ /\.(jpg|png|gif|webp)$/ {print $11}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -30

如果日志显示某个陌生域名在高频请求你的图片,那就是确凿的盗链。这时候的处理不是简单地把 none 删掉(删掉会影响那些正常的无 Referer 场景,比如用户直接访问、部分邮件客户端预览),而是改为「允许 none 但限速,允许白名单,其余返回一个占位图」:

location ~* \.(jpg|jpeg|png|gif|webp)$ {
    valid_referers none blocked server_names *.example.com example.com;
    if ($invalid_referer) {
        # 不返回 403(体验差),而是返回一张带水印的占位图
        rewrite ^ /static/anti-leech.png last;
    }
    expires 30d;
    add_header Cache-Control "public, immutable";
}

失效点二:if 在 location 里的执行时机与 add_header 的坑

第二个静默失效点非常隐蔽,和 if 指令在 Nginx 里的特殊地位有关。if ($invalid_referer) 这种写法是「rewrite 模块的 if」,它在 rewrite 阶段执行,网上那句著名的「if is evil」说的就是它的行为不符合直觉。具体到防盗链场景,有两个可观察的后果:

第一,如果你在同一条 if 里写 return 403,那么该请求会直接结束,后续的 expires、add_header 都不会生效——这倒不一定是坏事。但如果你在 if 里做的是 set $flag 1 然后在别处判断,就要注意 set 的变量作用域和继承:在 location 里 set 的变量,在 if 块内可以读写,但反过来在 if 块内 set 的变量出了块仍然有效,容易被误用。

第二,也是最常踩的:add_header 在 location 里有自己的层级规则——如果 if 块里没有 add_header,但 location 里有,是正常继承的;可一旦你在 if 块里加了任何 add_header,location 层的 add_header 会被屏蔽。所以防盗链的 if 块里最好什么都别加,只留 return 或 rewrite。验证方式很直接,用 curl 看响应头:

# 模拟盗链:伪造 Referer
curl -sI -H "Referer: https://evil-site.com/page.html" \
  "https://www.example.com/uploads/demo.jpg" | head -20

# 模拟正常访问:你自己的站
curl -sI -H "Referer: https://www.example.com/article.html" \
  "https://www.example.com/uploads/demo.jpg" | head -20

# 模拟无 Referer
curl -sI "https://www.example.com/uploads/demo.jpg" | head -20

三个请求分别应该看到 403(或占位图 200)、200 带 Cache-Control、200。如果第二个请求也返回了 403,说明白名单没覆盖到——最常见的原因是站点同时用了 www.example.com 和 example.com,而你只写了一个;或者配置里写的是 server_names 但当前 server 块的 server_name 根本没包含这个域名(比如所有域名都收敛到了一个默认 server 块,server_names 就只剩 _)。

失效点三:大小写、端口与请求方法导致的匹配失败

第三个点是纯细节,但它是「配置看上去和文档一模一样却不生效」的典型。Referer 的匹配是大小写不敏感的主机名匹配加上大小写敏感的路径部分(因为域名本身大小写不敏感),但真正的坑在下面几处:

一是端口号。如果 Referer 是 https://example.com:8443/page,白名单里只写 example.com 是不匹配的,必须写 example.com:8443 或用正则。反过来,带默认端口(443/80)的 Referer 通常会被浏览器省略端口,不需要额外处理。二是协议。部分老版本的匹配逻辑只看主机,但为了稳妥,如果站点同时有 http 和 https 的重定向链,建议在白名单里把两种形态都覆盖上。三是请求方法。盗链者完全可以对图片发 HEAD 或 POST 请求,如果你的防盗链规则被写在某个只处理 GET 的 location 里,那就完全绕过了。所以防盗链规则应该挂在按扩展名匹配的 location ~* \.(jpg|...)$ 上,而不是挂在某个业务路径下。

另外提醒一个和 SEO 相关的连带影响:如果你的图片资源被搜索引擎的图片搜索收录,这些请求的 Referer 可能是搜索引擎的域名。上面的正则白名单 ~\.google\. ~\.baidu\. ~\.bing\. 就是为了这个。如果误伤,图片搜索的流量会归零,但页面的收录通常不受影响——判断依据是 Search Console 里图片展示次数骤降。经验上建议把主流搜索引擎都加进去,盗链防护的重点是防「其他内容站整页搬运」,而不是防搜索引擎索引。

一套可直接抄的完整配置与验证流程

# 定义可复用的白名单变量,避免在多处 location 重复
map $http_referer $bad_ref {
    default                          0;
    ""                               0;   # 无 Referer 放行
    "~*^https?://(www\.)?example\.com/"   0;
    "~*^https?://[a-z0-9-]+\.example\.com/" 0;
    "~*^https?://(www\.)?(google|baidu|bing|sogou|so)\.com" 0;
    "~*^https?://(www\.)?(google|yandex)\.[a-z]{2,3}"      0;
    "~."                             1;   # 其余全部视为盗链
}

server {
    listen 443 ssl http2;
    server_name www.example.com example.com;

    location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
        if ($bad_ref) { return 403; }
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log /var/log/nginx/img.log main;
    }
}

用 map 而不是 valid_referers 的好处是:规则集中在一处、可读性强、能写更细的正则,并且 map 在请求处理的早期阶段求值,不受 if 阶段的怪异行为影响。配置改完后固定走这套验证:

nginx -t && systemctl reload nginx
# 盗链应 403
curl -s -o /dev/null -w "%{http_code}\n" -H "Referer: https://evil.com/" https://www.example.com/uploads/a.jpg
# 自己站应 200
curl -s -o /dev/null -w "%{http_code}\n" -H "Referer: https://www.example.com/p.html" https://www.example.com/uploads/a.jpg
# 无 Referer 应 200
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/uploads/a.jpg
# 观察一天后的真实效果
grep -c ' 403 ' /var/log/nginx/img.log

更进一步的防护:从防盗链到防盗刷

Referer 校验只是第一道门,它对「用脚本伪造 Referer 的盗链者」完全无效,因为 Referer 是客户端自己填的请求头,伪造成本为零。所以对于流量盗用比较严重的站点,需要叠加上第二道和第三道措施。第二道是签名 URL,给图片 URL 加一个带时效的签名参数,服务端校验签名和过期时间;第三道是限速,用 limit_req 或 limit_conn 对图片资源的单 IP 请求频率做约束。

签名 URL 的思路是缩短 URL 的有效期,让盗链者复制的 URL 很快失效。用一个带密钥的 map 或直接走 nginx 的 secure_link 模块都能实现:

# secure_link 模块方案,需要编译时带 --with-http_secure_link_module
location /protected/ {
    secure_link $arg_st,$arg_e;
    secure_link_md5 "$secure_link_expires$uri your_secret_key";

    if ($secure_link = "") { return 403; }   # 签名不匹配
    if ($secure_link = "0") { return 410; }  # 签名对但已过期

    # 通过了,正常提供文件
}

生成签名的方式是对 过期时间戳 + 资源路径 + 密钥 做 MD5,再用 URL 安全的 base64 编码。可以用一行 shell 或一段 PHP 生成:

# 生成一个 10 分钟后过期的签名 URL
URI="/protected/photo.jpg"
EXP=$(date -d "+10 minutes" +%s)
KEY="your_secret_key"
MD5=$(printf '%s%s %s' "$EXP" "$URI" "$KEY" | openssl md5 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "https://www.example.com${URI}?st=${MD5}&e=${EXP}"

注意 secure_link_md5 里字符串的拼接顺序和分隔符必须与生成端完全一致,这里用的是「过期时间 + URI + 空格 + 密钥」,分隔符多一个空格、少一个空格都会导致签名永远校验失败,这是接入时最常见的坑。验证方法很简单:先不加 st/e 参数访问,应该 403;再带上正确的签名访问,应该 200;最后把 e 改成一个过去的时间,应该 410。

限速层面,图片资源的合理阈值可以参考「同一 IP 每分钟不超过 600 个图片请求」,正常用户打开一个图文页最多几十个请求,超过这个量级的要么是爬虫要么是盗刷:

# 在 http 块里定义限速区
limit_req_zone $binary_remote_addr zone=imglimit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=imgconn:10m;

location ~* \.(jpg|jpeg|png|gif|webp)$ {
    limit_req zone=imglimit burst=30 nodelay;
    limit_conn imgconn 20;   # 单 IP 并发连接数上限
    if ($bad_ref) { return 403; }
    expires 30d;
}

burst=30 是允许的突发请求数,nodelay 表示突发部分立即处理而不是排队——不加 nodelay 会让图片加载变慢,用户体验受损;加了之后超限的直接 503。这里要特别小心别把搜索引擎的图片爬虫限死,如果站点依赖图片搜索流量,建议为已知爬虫 UA 单独放宽或跳过限速,用 map 定义一个条件变量来控制。

怎么区分「防盗链误伤」和「真的被盗链」

最后给一套判断依据,避免把正常流量误杀。真的被盗链的特征是:某个陌生 Referer 域名的请求量在日志里占比持续很高(比如超过 5%),且这些请求的 UA 通常是普通浏览器 UA 而不是爬虫,请求间隔规整。误伤的特征是:403 的比例在配置生效后突然上升,且这些 403 请求的 Referer 大多为空或者是一个你认识的自己的子域。用一条命令把两者分开:

# 统计被 403 的图片请求,按 Referer 聚合,看是不是误伤
awk '$9 == 403 && $7 ~ /\.(jpg|png|gif|webp)$/ {print $11}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

# 对比防盗链上线前后的图片请求总量,判断流量是否真的有下降
grep -c '\.jpg' /var/log/nginx/access.log

如果 403 的清单里出现了自己的子域(比如 cdn.example.com 或 img.example.com),那就是白名单漏了,补上即可;如果出现的是空 Referer 的大量请求,且这些请求集中在移动端 UA 上,很可能是某个 App 的内嵌浏览器不发送 Referer,这种情况下应该允许 none 并对这些 UA 单独观察,而不是一刀切封掉。

总结一下这一篇的重点:$invalid_referer 是「有没有命中白名单」的反向标记,不是白名单标记;none 的存在意味着无 Referer 请求全部放行,套 CDN 后这一点会让防盗链形同虚设;if 块里加 add_header 会屏蔽外层响应头;端口的参与匹配是最容易被忽略的一处。想省心就用 map 方案,写完必须用三种 Referer 形态去 curl 验证,别只看配置文件像不像对的。Referer 之外再考虑签名 URL 和限速,三者叠加才是完整的资源保护,但每加一层都要用日志验证有没有误伤正常流量。

Last modification:September 27th, 2026 at 12:24 pm

Leave a Comment