拿不到回源 IP 段,也能安全拿到真实 IP:EdgeOne + Cloudflare 双 CDN 实战

EdgeOne 与 Cloudflare 双 CDN 获取真实 IP 示意图

一个导航站接入 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 段清单你根本拿不到。

这套方案有三个前提:

  1. 密钥足够长(≥32 位随机),只存在于 CDN 配置和源站配置里;
  2. 源站 IP 尽量不暴露(域名只解析到 CDN);
  3. 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

十、这套方案的边界

它不是银弹,有三个必须守住的边界:

  1. 密钥泄露 = 通道失守。密钥一旦泄露,攻击者可以伪造任意 IP。所以:密钥 ≥32 位随机、定期轮换、不写进代码仓库、配置文件权限收紧。
  2. 源站 IP 要藏。密钥方案的前提是「攻击者找不到源站」。域名只解析到 CDN,源站 IP 不出现在证书、邮件头、历史 DNS 记录里。
  3. 能放在边缘的防护就放在边缘。限流、CC 防护、Bot 管理尽量用 CDN 自带能力,源站只负责「认出真实 IP」和「按真实 IP 记录/风控」。

回到最初的问题:免费版拿不到回源 IP 段,确实不能照抄 0.0.0.0/0。但「信任来源」未必要用 IP 来证明——一串随机密钥,效果一样,还更可控。

×
喜欢这篇文章吗?
如果这篇文章对你有帮助,可以扫码打赏支持我一下。
微信
微信赞赏码
支付宝
支付宝赞赏码
×
长按保存图片,分享给朋友
文章海报
© 版权声明

相关文章