正确获取客户端 IP/HTTP Header 也可能重复

背景

鉴于各大平台均已支持评论地区显示,所以有必要考虑也加上类似的功能

(严格来说,国内做网站所有发布功能必须留档,因此记录 IP 是必须的审查步骤)

从原理上而言,实现这个功能并不难

  1. 从 请求头 查询 X-Forwarded-For ,提取客户端 IP
  2. 调用接口获取 IP 对应的地址

这东西旧的 Python 版本博客实际上就已经做过了,本来以为前后端改完,编译打镜像部署加起来最多 15 分钟就能解决战斗。结果没想到断断续续折腾了两周(虽然大部分时间是在上班和躺尸)

X-Forwarded-For

通常而言,教程里往往会使用 “X-Forwarded-For 的第一个地址“ 作为客户端 IP,某种意义上这个是没有问题的。

X-Forwarded-For 在设计上是每一个代理将自己认为的客户端 IP 填写在后面,如下图用户的请求通过 3 跳代理转发至真正的服务端,每个代理都会将与自己通信的客户端 IP 添加在 X-Forwarded-For 最后面。

User
1.0.0.1
Proxy 1
2.0.0.1
<code>X-Forwarded-For: 1.0.0.1</code>
Proxy 2
2.0.0.2
<code>X-Forwarded-For: 1.0.0.1, 2.0.0.1</code>
Proxy 3
2.0.0.3
<code>X-Forwarded-For: 1.0.0.1, 2.0.0.1, 2.0.0.2</code>
Server
3.0.0.1
<code>X-Forwarded-For: 1.0.0.1, 2.0.0.1, 2.0.0.2, 2.0.0.3</code>

设计上是这样,但仍然需要手动配置各级代理,以常用的 Nginx 为例,在反向代理部分,需要添加如下内容

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

可以将这段翻译为,可以看出只要各级按照规范转发,无论存在多少跳转发,无论自己处在转发链路的任意位置,转发链上的任一成员都可以正确拿到真正的客户端 IP

let headers = request.headers;
let remote_addr = request.src_ip;

function proxy_add_x_forwarded_for() {
    return headers["X-Forwarded-For"].split(",").concat([remote_addr]).join(",")
}

headers["X-Real-IP"] = remote_addr;
headers["X-Forwarded-For"] = proxy_add_x_forwarded_for();

从上面的部分,也可以解释为什么这里不能配置为 proxy_set_header X-Forwarded-For $remote_addr;

博客转发链路

目前而言,博客以这样的形式部署,最前方有 CDN 为用户访问加速;服务器上部署有第一层 Nginx 转发,将请求根据域名转发至不同的应用上;进入博客系统后,还要根据路径将请求转发到前端或是后端上

User
CDN
Server Nginx
Blotter Nginx
Blotter Frontend
Blotter Backend
Other App

由于可以确定 "Blotter Nginx" 不会直接承载外部流量,因此可以不再配置 X-Forwarded-For(配置了也只会加上一个内网地址,没有意义)

头部重复的情况

经过抓包排查,问题出自 ”Server Nginx“ 这里。为了方便配置,服务器上使用的是 NginxProxyManager/nginx-proxy-manager 进行可视化配置,但其中模板中生成的配置是 proxy_set_header X-Forwarded-For $remote_addr;,因此当带有 CDN 时,这里获取的是 CDN 的 IP 地址。

而如果手动在反向代理中配置 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;,则会导致 Header 重复的情况,得到如下的请求

GET /tag/answer?page=5&size=10 HTTP/1.1
Host: www.ohyee.cc
X-Forwarded-Scheme: https
X-Forwarded-Proto: https
X-Forwarded-For: 113.219.202.153
X-Real-IP: 113.219.202.153
Connection: keep-alive
X-Real-IP: 113.219.202.153
X-Forwarded-For: 180.101.245.250, 113.219.202.153
User-Agent: Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: max-age=0
Referer: http://www.ohyee.cc/tag/answer?page=5&size=10
Upgrade-Insecure-Requests: 1
40b994b7734b547d3ec2e5b7c6b31a9b: tag
X-Lego-Via: 200488
X-NWS-LOG-UUID: 12240839787883520856
97f2889f805b289210498d7ca1916fbc: tag
X-Tencent-Ua: Qcloud

可以看到这里存在两个 X-Forwarded-For

  • X-Forwarded-For: 113.219.202.153
  • X-Forwarded-For: 180.101.245.250, 113.219.202.153

对于很多部分程序,在处理该问题时就会出现问题(而且并不罕见

需要注意的是,在 RFC 规范中,对于类似 X-Forwarded-For 这种支持逗号分隔的头部,是 允许存在重复头部 的,且处理中必须保证头部顺序拼接起来。

解决方案

Go 程序处理

遍历 request 头部时,拿到的头部为如下结构 []string{"113.219.202.153", "180.101.245.250, 113.219.202.153"},因此可以考虑取最后一个元素中的第一个 IP

func getIPFromHeader(header *http.Header, headerName string) string {
	IPs := header.Values(headerName)

	if len(IPs) > 0 {
		remoteIP := IPs[len(IPs)-1]
		arr := strings.Split(remoteIP, ",")
		if len(arr) > 0 {
			return strings.TrimSpace(arr[0])
		}
	}
	return ""
}

从本质上解决

NginxProxyManager/nginx-proxy-manager 在接收到 HTTP 请求时,会通过 proxy_set_header X-Forwarded-For $remote_addr; 设置 CDN IP,而后在具体转发中通过 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 设置根据请求体头部生成的头部,从而导致了重复头部。

在这里虽然符合支持存在重复头部的规范,但违反了 X-Forwarded-For 本身的规范(实际上这个不能称为规范,只能叫做共识),因此仍然属于 BUG。

因此,可以替换为修复相关模板后的镜像 ohyee/nginx-proxy-manager:latest,该镜像相关的改动见如下 diff

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3d46a6e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,5 @@
+FROM jc21/nginx-proxy-manager:latest
+
+RUN sed -i 's/\(X-Forwarded-For.*\)\$remote_addr/\1\$proxy_add_x_forwarded_for/gm' /etc/nginx/conf.d/production.conf
+RUN sed -i 's/\(X-Forwarded-For.*\)\$remote_addr/\1\$proxy_add_x_forwarded_for/gm' /etc/nginx/conf.d/include/proxy.conf 
+RUN sed -i 's/\(X-Forwarded-For.*\)\$remote_addr/\1\$proxy_add_x_forwarded_for/gm' /app/templates/_location.conf
diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf
index 5a7a6ab..e820e4f 100644
--- a/backend/templates/_location.conf
+++ b/backend/templates/_location.conf
@@ -2,7 +2,7 @@
     proxy_set_header Host $host;
     proxy_set_header X-Forwarded-Scheme $scheme;
     proxy_set_header X-Forwarded-Proto  $scheme;
-    proxy_set_header X-Forwarded-For    $remote_addr;
+    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
     proxy_set_header X-Real-IP		$remote_addr;
     proxy_pass       {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }};
 
diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf
index edbdec8..b4f171b 100644
--- a/docker/rootfs/etc/nginx/conf.d/dev.conf
+++ b/docker/rootfs/etc/nginx/conf.d/dev.conf
@@ -15,7 +15,7 @@ server {
 		proxy_set_header Host $host;
 		proxy_set_header      X-Forwarded-Scheme $scheme;
 		proxy_set_header      X-Forwarded-Proto  $scheme;
-		proxy_set_header      X-Forwarded-For    $remote_addr;
+		proxy_set_header      X-Forwarded-For    $proxy_add_x_forwarded_for;
 		proxy_pass            http://127.0.0.1:3000/;
 
 		proxy_read_timeout 15m;
diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf
index fcaaf00..d346c4e 100644
--- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf
+++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf
@@ -2,7 +2,7 @@ add_header       X-Served-By $host;
 proxy_set_header Host $host;
 proxy_set_header X-Forwarded-Scheme $scheme;
 proxy_set_header X-Forwarded-Proto  $scheme;
-proxy_set_header X-Forwarded-For    $remote_addr;
+proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
 proxy_set_header X-Real-IP          $remote_addr;
 proxy_pass       $forward_scheme://$server:$port$request_uri;
 
diff --git a/docker/rootfs/etc/nginx/conf.d/production.conf b/docker/rootfs/etc/nginx/conf.d/production.conf
index 877e51d..7c1c7ab 100644
--- a/docker/rootfs/etc/nginx/conf.d/production.conf
+++ b/docker/rootfs/etc/nginx/conf.d/production.conf
@@ -16,7 +16,7 @@ server {
 		proxy_set_header Host $host;
 		proxy_set_header      X-Forwarded-Scheme $scheme;
 		proxy_set_header      X-Forwarded-Proto  $scheme;
-		proxy_set_header      X-Forwarded-For    $remote_addr;
+		proxy_set_header      X-Forwarded-For    $proxy_add_x_forwarded_for;
 		proxy_pass            http://127.0.0.1:3000/;
 
 		proxy_read_timeout 15m;

参考资料