一个导航站接入 EdgeOne 后,日志里全是 CDN 节点 IP。免费版拿不到回源 IP 段,网上流传的做法是把
set_real_ip_from设成0.0.0.0/0——这等于把伪造 IP 的钥匙发给所有人。本文记录一套不依赖 IP 白名单的替代方案:用共享密钥头证明「你确实来自 CDN」,再采信它带来的真实 IP。
一、接入 CDN 之后,源站还剩下什么
网站接入 CDN 后,用户请求先到边缘节点,再由节点回源。源站 Nginx 的 $remote_addr 自然就变成了节点 IP。
这会带来一连串问题:
- 访问日志里全是节点 IP,排查攻击、分析访客、看地区分布全部失真;
- WordPress 的评论、登录限流、统计插件拿到的都是节点 IP;
- 按 IP 做的封禁、限流、风控,等于在按 CDN 节点做,误伤一片。
常规解法是「回源 IP 白名单」:CDN 厂商提供回源节点 IP 段,源站用 Nginx 的 realip 模块只信任这些网段。
问题在于,不是所有套餐都给这份清单。腾讯云 EdgeOne 免费版就查不到回源 IP 段。于是很多人退而求其次:
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
这两行的意思是:任何来源的请求,只要带 X-Forwarded-For,源站就信。任何人找到源站 IP,自己带一个 X-Forwarded-For: 1.2.3.4,日志里就是 1.2.3.4。伪造零成本,比不配还危险。
二、换个思路:把「IP 白名单」换成「密钥白名单」
IP 白名单的本质是「证明你从 CDN 来」。既然 IP 证明不了,那就换一种证明:共享密钥。
- CDN 回源时注入一个只有 CDN 和源站知道的请求头,比如
X-ZT-Edge-Auth: <随机长串>; - 源站只信任「密钥正确」的请求,采信它带来的
ZTSITE-Real-IP; - 密钥不对或没带密钥的请求,一律回退到连接 IP——伪造头自然失效。
它和 IP 白名单解决的是同一个问题:请求是不是 CDN 发来的。区别是密钥可以轮换、不能被枚举,而 IP 段清单你根本拿不到。
这套方案有三个前提:
- 密钥足够长(≥32 位随机),只存在于 CDN 配置和源站配置里;
- 源站 IP 尽量不暴露(域名只解析到 CDN);
- CDN 侧对同名头做「覆盖」而不是「追加」,避免双头。
下面按 EdgeOne、Cloudflare 两条链路分别配置,最后落到源站 Nginx。
三、CDN 侧:EdgeOne 怎么配
EdgeOne 的入口在站点加速 → 规则引擎,用 HOST 匹配域名。
顺序不能反:先删、后设。
| 操作 | 头 | 值 |
|---|---|---|
| 删除 | CF-Connecting-IP、X-Real-IP、X-Client-IP、X-ZT-Edge-Auth、ZTSITE-Real-IP |
— |
| 设置 | ZTSITE-Real-IP |
${http.request.ip} |
| 设置 | X-ZT-Edge-Auth |
<EO_KEY> |
两个细节决定成败:
1. 类型必须选「设置」,不能选「增加」。
「增加」遇到同名头不会覆盖,而是并存。攻击者只要自己带一个 X-ZT-Edge-Auth: whatever,源站收到的就是两个同名头,Nginx 的 $http_x_zt_edge_auth 变量会变成 EO_correct, whatever 这种拼接值,= 比较必然失败,真实 IP 直接丢。更糟的是,如果你按「取第一个值」来写校验,攻击者的值就排在了前面。
「设置」是覆盖语义,同名头只保留一个。
2. EO-Connecting-IP 和 X-Forwarded-For 改不了。
它们在 EdgeOne 的「不可配置头部」清单里,所以不能直接复用,只能另起一个自定义头。这也是本文用 ZTSITE-Real-IP 和 X-ZT-Edge-Auth 的原因。
四、CDN 侧:Cloudflare 怎么配
Cloudflare 新版控制台的入口搬家了:Rules → Overview → Create rule → Request Header Transform Rule,左侧不再单列子菜单。
免费版限制:每个域名 10 条规则、每条最多改 30 个请求头,一条规则足够。
| 类型 | 头 | 值 |
|---|---|---|
| Remove | EO-Client-IP、EO-Connecting-IP |
— |
| Set static | X-ZT-Edge-Auth |
<CF_KEY> |
| Set dynamic | ZTSITE-Real-IP |
to_string(ip.src) |
几个要点:
- Set 会覆盖同名已有头,所以不需要先删自己要设的头;Remove 只针对另一条 CDN 链路遗留的头。
ip.src是 Cloudflare 的可信字段,等价于CF-Connecting-IP,不解析 XFF,不会被客户端伪造。X-Forwarded-For、X-Real-IP、True-Client-IP属于保留头,改不了值,所以同样另起自定义头。- Network → Pseudo IPv4 不要设成
Overwrite Headers,否则日志里会出现一堆240.x开头的假地址,真实 IP 全被覆盖。 - UI 找不到入口就用 Rulesets API:
phase=http_request_late_transform、action=rewrite,用POST /rulesets/{id}/rules追加规则;不要用PUT整个 ruleset,会覆盖掉已有规则。
五、两条链路最容易翻车的地方:交叉伪造
双 CDN 场景下,源站可能同时收到两种请求:
- 从 EdgeOne 进来的,带
EO的密钥和ZTSITE-Real-IP; - 从 Cloudflare 进来的,带
CF的密钥和ZTSITE-Real-IP。
如果源站按「CF-Connecting-IP → EO-Connecting-IP → X-Forwarded-For → RemoteAddr」这种优先级列表取值,攻击者可以从 EdgeOne 通道带一个假的 CF-Connecting-IP,源站一看「有 CF 头」,就采信了——跨通道伪造。
正确做法只有一条:只按密钥判断通道,只采信该通道的头。
密钥是 EO_xxx,就只读 EdgeOne 那条链路注入的 IP 头;密钥是 CF_xxx,就只读 Cloudflare 注入的那一个。不要维护「头名优先级列表」,那是给攻击者留后门。
另外,串联架构(CF → EdgeOne → 源站)不要用。中间那层 CDN 看到的客户端是上一跳 CDN 的节点 IP,ZTSITE-Real-IP 拿到的会是节点 IP 而不是用户 IP。要么两条链路各自独立接入,要么只保留一层。
六、源站 Nginx:密钥校验 + 真实 IP 覆盖
核心是三个 map,加上一层格式兜底。
# 1) 密钥是否正确
map $http_x_zt_edge_auth $zt_trusted {
default 0;
"<EO_KEY>" 1;
"<CF_KEY>" 1;
}
# 2) 密钥正确才取 CDN 带来的 IP,否则留空
map $zt_trusted $zt_ip_candidate {
1 $http_ztsite_real_ip;
default "";
}
# 3) 格式兜底:IPv4 / IPv6 / 回环,其余退回连接 IP
map $zt_ip_candidate $zt_client_ip {
"~^(d{1,3}.){3}d{1,3}$" $zt_ip_candidate;
"~^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{0,4}){1,7}$" $zt_ip_candidate;
"~^::1$" $zt_ip_candidate;
default $remote_addr;
}
为什么用 map 而不是在 server 里 set?因为 log_format 和 limit_*_zone 都在 http 层,需要在那个阶段就能看到 $zt_client_ip,map 正好满足。
然后处理「哪些请求可以绕过密钥」:
# 本机、源站自身公网 IP、Docker 网段(geo 基于连接 IP,不可伪造)
geo $zt_local {
127.0.0.1/32 1;
::1/128 1;
<源站公网IP>/32 1; # 源站自身公网 IP(wp-cron、面板自测)
172.16.0.0/12 1;
default 0;
}
# 内部脚本通道:复用 API Key(直连源站的运维脚本用)
map $http_x_agent_api_key $zt_internal {
default 0;
"<AGENT_API_KEY>" 1;
}
server {
# ACME 续期必须放行:server 级 if 在 location 匹配之前执行,
# 只靠 location 的 allow all 挡不住 444
set $zt_acme 0;
if ($uri ~ ^/.well-known/acme-challenge/) { set $zt_acme 1; }
set $zt_block "$zt_trusted:$zt_local:$zt_internal:$zt_acme";
if ($zt_block = "0:0:0:0") { return 444; }
然后是让 PHP 拿到真实 IP。每一个 fastcgi_pass 的 location 都要单独加,因为 include fastcgi_params; 自带 REMOTE_ADDR $remote_addr,会挡住 server 级的继承:
location ~ [^/].php(/|$) {
fastcgi_pass 127.0.0.1:9001;
include fastcgi-php.conf;
include fastcgi_params;
fastcgi_param REMOTE_ADDR $zt_client_ip;
fastcgi_param HTTP_X_FORWARDED_FOR $zt_client_ip;
fastcgi_param HTTP_X_REAL_IP $zt_client_ip;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
日志和限流也顺手换掉:
log_format ztsite '$zt_client_ip - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for"';
limit_conn_zone $zt_client_ip zone=zt_conn:10m;
limit_req_zone $zt_client_ip zone=zt_sites_rate:10m rate=30r/m;
如果沿用原来的
limit_conn perip 25,它按$binary_remote_addr计数,也就是按 CDN 节点 IP 计数。一个边缘节点回源并发超过 25,所有走这个节点的用户一起 503。这个坑比真实 IP 本身更容易先炸。
七、灰度上线:444 的坑比配置本身多
配置写对只是第一步,真正容易出事的是「打开拦截」的那一刻。
先开日志,后开 444。 用一个独立的 debug 格式记录每条请求的通道和校验结果:
log_format zt_debug '$remote_addr via=$zt_via trusted=$zt_trusted '
'internal=$zt_internal local=$zt_local acme=$zt_acme '
'realip="$http_ztsite_real_ip"';
确认 CDN 回源 trusted=1、内部脚本 internal=1,再打开 return 444。
打开之后,下面这些都可能被误伤:
| 场景 | 现象 | 处理 |
|---|---|---|
| ACME 证书续期 | 续期失败 | 在 server 级放行 /.well-known/acme-challenge/ |
| wp-cron 自请求 | 计划任务不执行 | geo 放行源站自身公网 IP |
| 运维脚本直连源站 | 接口全 444 | 加内部通道头,或走域名 |
| 裸域漏配规则 | 裸域 520、主域正常 | CDN 规则 Host 改成 *.example.com |
| EdgeOne 健康检查 | 若被 444,可能整站熔断 | 健康检查也带头,或先不开 444 |
| 非浏览器 UA | curl 返回 567 | 多数是 CDN 的 UA 拦截,不是源站故障 |
最后一行值得单独说:EdgeOne 对 curl、空 UA 这类请求可能直接返回 567 错误页,这是 CDN 层的 UA 策略,不是源站挂了。用正常浏览器 UA 测一遍就能区分:
UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36'
curl -sS -o /dev/null -w '%{http_code}n' -A "$UA" https://www.example.com/
八、验证:三条 curl 定生死
配置完成后,至少跑这三条:
# ① 直连源站,伪造 IP,不带密钥 → 应 444,且日志不采信 1.2.3.4
curl -k --resolve www.example.com:443:<源站IP> https://www.example.com/
-H 'ZTSITE-Real-IP: 1.2.3.4' -H 'X-ZT-Edge-Auth: wrong'
# ② 直连源站,正确密钥 + 伪造 IP → 应 200,且日志首字段是 1.2.3.4
curl -k --resolve www.example.com:443:<源站IP> https://www.example.com/
-H 'ZTSITE-Real-IP: 1.2.3.4' -H 'X-ZT-Edge-Auth: <真实密钥>'
# ③ 正常走 CDN → 应 200,日志首字段是你的公网 IP
curl -sS -o /dev/null -w '%{http_code}n' -A "$UA" https://www.example.com/
第 ② 条是关键:它证明「密钥正确时 IP 头被采信」,同时证明「密钥错误时不会被采信」。
另外别忘测爬虫 UA。如果 CDN 的 UA 策略把 bingbot、Googlebot 也拦了,SEO 会直接受伤:
for ua in "bingbot" "Googlebot" "Baiduspider"; do
curl -sS -o /dev/null -w "$ua %{http_code}n" -A "Mozilla/5.0 (compatible; $ua/2.0)" https://www.example.com/
done
九、踩坑清单
| # | 坑 | 后果 | 解法 |
|---|---|---|---|
| 1 | set_real_ip_from 0.0.0.0/0 |
任何人可伪造 IP | 换成密钥校验 |
| 2 | CDN 用「增加」而非「设置」 | 同名头并存,校验失效 | 先删后设,选「设置」 |
| 3 | 跨通道伪造 | 从 A 通道带 B 通道的头 | 只按密钥判通道 |
| 4 | 串联 CF → EO | 真实 IP 变成上一跳节点 IP | 不串联 |
| 5 | 裸域没配规则 | 裸域 520/444 | Host 用 *.domain.com |
| 6 | 限流按节点 IP | 单节点并发打满,批量 503 | zone 换 $zt_client_ip |
| 7 | 444 误伤 ACME/wp-cron | 续期失败、定时任务停摆 | 显式放行 |
| 8 | debug 日志打印密钥 | 密钥落盘 | 只打 trusted=0/1 |
| 9 | 密钥明文进 git/截图 | 通道被伪造 | 轮换、配置权限 640 |
十、这套方案的边界
它不是银弹,有三个必须守住的边界:
- 密钥泄露 = 通道失守。密钥一旦泄露,攻击者可以伪造任意 IP。所以:密钥 ≥32 位随机、定期轮换、不写进代码仓库、配置文件权限收紧。
- 源站 IP 要藏。密钥方案的前提是「攻击者找不到源站」。域名只解析到 CDN,源站 IP 不出现在证书、邮件头、历史 DNS 记录里。
- 能放在边缘的防护就放在边缘。限流、CC 防护、Bot 管理尽量用 CDN 自带能力,源站只负责「认出真实 IP」和「按真实 IP 记录/风控」。
回到最初的问题:免费版拿不到回源 IP 段,确实不能照抄 0.0.0.0/0。但「信任来源」未必要用 IP 来证明——一串随机密钥,效果一样,还更可控。

