正确获取客户端 IP/HTTP Header 也可能重复
背景
鉴于各大平台均已支持评论地区显示,所以有必要考虑也加上类似的功能
(严格来说,国内做网站所有发布功能必须留档,因此记录 IP 是必须的审查步骤)
从原理上而言,实现这个功能并不难
- 从 请求头 查询
X-Forwarded-For
,提取客户端 IP - 调用接口获取 IP 对应的地址
这东西旧的 Python 版本博客实际上就已经做过了,本来以为前后端改完,编译打镜像部署加起来最多 15 分钟就能解决战斗。结果没想到断断续续折腾了两周(虽然大部分时间是在上班和躺尸)
X-Forwarded-For
通常而言,教程里往往会使用 “X-Forwarded-For
的第一个地址“ 作为客户端 IP,某种意义上这个是没有问题的。
X-Forwarded-For
在设计上是每一个代理将自己认为的客户端 IP 填写在后面,如下图用户的请求通过 3 跳代理转发至真正的服务端,每个代理都会将与自己通信的客户端 IP 添加在 X-Forwarded-For
最后面。
设计上是这样,但仍然需要手动配置各级代理,以常用的 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 转发,将请求根据域名转发至不同的应用上;进入博客系统后,还要根据路径将请求转发到前端或是后端上
由于可以确定 "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;