CloudFront + ALB 后面取访客 IP:为什么不要在 PHP 里解析 X-Forwarded-For

官方 WordPress 镜像的 mod_remoteip 已改写 X-Forwarded-For,PHP 里再取倒数第二项会拿到访客伪造的值。改用 CloudFront-Viewer-Address,并说明它成立的两个前提和验证方法。

WordPress 放在 CloudFront 和 ALB 后面时,PHP 看到的 REMOTE_ADDR 不是访客的地址。常见的修法是在 wp-config.php 里自己解析 X-Forwarded-For,取“倒数第几个”条目。本站就这样写过,结果访客可以随意指定自己的 IP。原因是官方 WordPress 镜像里的 Apache 已经用 mod_remoteip 处理并改写过这个头。本文按一次请求的实际经过拆开这个问题,再给出改用 CloudFront-Viewer-Address 的修法和它成立的前提。

请求链上的 X-Forwarded-For

假设访客的真实地址是 198.51.100.10,访客在请求里自己带了一个伪造的头。下面的地址都是文档用的示例地址:

GET / HTTP/1.1
Host: www.example.com
X-Forwarded-For: 203.0.113.66        ← 访客自己写的

各环节依次这样处理:

环节动作此后的 X-Forwarded-For
CloudFront把 TCP 连接上看到的访客地址追加到末尾203.0.113.66, 198.51.100.10
ALB(默认 append 模式)把连到它的客户端,也就是 CloudFront 边缘节点的地址追加到末尾203.0.113.66, 198.51.100.10, 192.0.2.199
Apache mod_remoteip见下一节203.0.113.66, 198.51.100.10

前两步是 CloudFront 和 ALB 文档里写明的行为。如果只看这两步,“倒数第二个条目是访客”似乎没错:最后一个是边缘节点,倒数第二个是 CloudFront 追加的访客地址,访客没法伪造。本站最初就是这么写的:

// 有问题的写法:再解析一遍 X-Forwarded-For,取倒数第二个
$xff = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
$_SERVER['REMOTE_ADDR'] = $xff[max(0, count($xff) - 2)];

mod_remoteip 已经先处理过一遍

官方 wordpress:*-apache 镜像启用了 mod_remoteip,配置是:

RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
RemoteIPInternalProxy 169.254.0.0/16
RemoteIPInternalProxy 127.0.0.0/8

按 Apache 文档,mod_remoteip 从右往左处理这个头;遇到一个不被信任的地址就停下,把它作为客户端地址,并把头改写成“剩下还没确认的地址”;如果全部可信,就删掉整个头。

代入上面的例子:连到 Apache 的是 ALB,地址在 VPC 私有网段里,属于受信任的内部代理。它给出的最右条目 192.0.2.199 是边缘节点的公网地址,不在信任列表里,处理到此为止。结果是:

  • Apache 和 PHP 的 REMOTE_ADDR 等于 192.0.2.199,是 CloudFront 边缘节点;
  • 传给 PHP 的 X-Forwarded-For 已经被改写成 203.0.113.66, 198.51.100.10,最右那一项被“用掉”了。

这时 PHP 再取倒数第二个,拿到的是 203.0.113.66,正好是访客伪造的值。访客不带这个头时,改写后只剩 198.51.100.10 一项,max(0, count - 2) 取到下标 0,结果正确。所以正常测试全部通过,只有刻意伪造时才出错。

更早没有 CloudFront、只有 ALB 时,代码取的是最后一项,问题一样:mod_remoteip 已经把 ALB 追加的访客地址用掉了,剩下的最后一项又是访客自己写的。结论是:Apache 前面已经有 mod_remoteip 时,PHP 看到的 X-Forwarded-For 只剩下访客能控制的部分,不应该再解析。

修法:用 CloudFront-Viewer-Address

CloudFront 可以在回源请求里加 CloudFront-Viewer-Address 头,值是访客的 IP 加源端口,例如 198.51.100.10:46532。它不会自动出现,需要回源请求策略把它带上:托管策略 AllViewerAndCloudFrontHeaders-2022-06 包含它,也可以在自定义回源请求策略里单独加。这个头只能放进回源请求策略,不能放进缓存策略。

在 wp-config.php 里(本站通过官方镜像的 WORDPRESS_CONFIG_EXTRA 注入)去掉末尾的端口即可:

if (!empty($_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS'])) {
    $_SERVER['REMOTE_ADDR'] = preg_replace('/:\d+$/', '', $_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS']);
}

正则只去掉最后一个“冒号加数字”,所以 IPv6 地址本身的冒号不受影响。这段代码在 wp-config.php 里执行,早于插件加载,插件里的限流、日志、评论记录拿到的都是改过之后的地址。

这个头为什么可以信

单靠换一个头名并不安全,需要两个前提同时成立:

  1. 值由 CloudFront 生成。AWS 文档说明这个头里是 CloudFront 看到的访客地址和端口。本站 README 记录的结论是,访客自己带的同名头会被 CloudFront 覆盖。AWS 文档里没有找到明确写出“覆盖”的句子,上线前建议在自己的环境里用一个回显请求头的临时页面确认一次。
  2. 源站只接受来自 CloudFront 的请求。否则攻击者可以绕过 CloudFront 直连 ALB,自己写一个 CloudFront-Viewer-Address。本站的 ALB 规则除了匹配 Host,还要求一个只有 CloudFront 会添加的秘密头,不带这个头的请求落到监听器的默认 404。

只有在第 2 条成立的环境里,才启用上面那行 PHP。本站 CDK 代码里,这行只在配置了 CloudFront 分发时才会生成。

怎么验证

带一个伪造的头请求站点,再看 WordPress 记下的地址,比如在测试文章下发一条评论,到后台看评论者 IP:

curl -s -H 'X-Forwarded-For: 203.0.113.66' https://www.example.com/ -o /dev/null

修复前,后台看到的会是 203.0.113.66;修复后应当是你自己的出口地址。同时要确认绕过 CloudFront 直连源站被拒绝:带正确的 Host、不带秘密头请求 ALB,应该拿到默认动作的响应,而不是 WordPress 页面。

局限 / 注意

  • 这行代码只改 PHP 里的 $_SERVER['REMOTE_ADDR']。Apache 访问日志里记录的仍是 mod_remoteip 得出的边缘节点地址。
  • 方案绑定 CloudFront。以后如果在前面再加一层代理或换 CDN,要重新梳理整条链路。
  • 另一条思路是让 mod_remoteip 信任 CloudFront 的回源地址段,由 Apache 直接得出访客地址。代价是要持续维护这份地址列表,本站没有采用。
  • ALB 的 routing.http.xff_header_processing.mode 可以设为 append、preserve 或 remove,共享 ALB 上改它会影响所有后端,本站保持默认。

链接