
你是不是也遇到过这种情况线上访问日志里记录的IP五花八门有时候冒出来一长串像接力跑的成绩单——203.0.113.5, 10.10.2.1, 198.51.100.3根本分不清哪个是真实用户哪个是CDN节点。我上周排查一个用户登录设备风控的接口发现同一个用户在同一秒IP一会儿是新加坡的CDN节点一会儿是内网网关段算出来的登录地域比坐高铁还快。查了一圈根子不在Nginx配置而在业务代码里对请求头无脑信任。这篇文章我就用Python手写一套真实客户端IP解析工具顺带把转发链路里常见的头部信息泄露、伪造绕过、误判误杀这些坑一个个拆开讲清楚。不管你是写业务接口的后端、搭网关的运维还是做数据分析想按IP统计用户画像这套思路都能直接拿去用。1. 转发链路里那个真实IP两件完全不一样的事1.1 先分清TCP对端地址和链路标记地址很多刚接触网络编程的同学第一次看到REMOTE_ADDR时都以为这就是用户IP。理论上没错但它代表的是与你建立TCP连接的那个对端地址不一定是发起请求的真实用户地址。举个例子用户在你网站前面隔了一台CDN节点那么你的Nginx看到的TCP对端就是CDN节点的出口IP而不是用户的宽带IP。这就像你通过前台收快递快递单上写的是用户的名字但快递员实际接触的是你们的物业前台。REMOTE_ADDR告诉你的是刚才谁敲门了也就是前台而真正的收件人信息写在快递单的备注栏里——对应到HTTP协议就是X-Forwarded-For这类经过沿途追加的头部字段。这里的关键区别在于REMOTE_ADDR是传输层记录由TCP连接决定很难伪造但它描述的是最后一跳。X-Forwarded-For是应用层标记由沿途的转发服务追加可以被用户或中间节点修改。所以取到IP和取到真实IP是两件事。取到IP只需要读一个变量取到真实IP则需要你理解这条链路把IP标注在了哪里以及哪些标记是可信任的。1.2 为什么取到IP不等于取到真实IP先说结论请求头里的任何字段默认都是不可信的。因为HTTP请求头是客户端或者中间转发服务带进来的文本只要我能连上你的服务器我就能往X-Forwarded-For里塞任意内容。当你的应用只有一个入口用户直接连接服务器时你当然可以直接信任REMOTE_ADDR。一旦架构变成了用户 → CDN节点 → 负载均衡 → 网关服务 → 业务后端每一跳都会在链路标记上做文章最常见的是在X-Forwarded-For后追加自己的节点地址。这时候你如果傻乎乎地取最左边那个值等于把用户随手写的字符串当成真实身份。有人可能会在X-Forwarded-For里写1.2.3.4, 5.6.7.8还有人会写上内网地址甚至塞一段非IP的脏数据。你在日志里看到的所有异常IP多半都是这个原因。所以解析真实客户端IP的核心思路不是找最左或者找第一个合法地址而是根据你信任的转发链路从最右侧开始逐一裁剪一直裁到你认为可信的第一个节点为止。这句话是整个工具的灵魂后面代码全部围绕它展开。1.3 典型链路CDN 负载均衡 网关我画一条最常见的生产链路给你看遇到的情况基本都能归到这几种用户浏览器发请求原始IP为用户IP(100.64.1.1)。请求先到CDN节点CDN在X-Forwarded-For里追加CDN出口IP(200.10.10.5)此时头变成100.64.1.1, 200.10.10.5。再到负载均衡它追加LB出口IP(172.16.0.1)头变成100.64.1.1, 200.10.10.5, 172.16.0.1。最后到你的后端REMOTE_ADDR是172.16.0.1或网关IP。你站在后端看这条链路真实用户IP在最左边但你并不能直接相信最左边因为用户自己完全可以在请求时先写一个假的X-Forwarded-For: 8.8.8.8于是到达你这里的头可能是8.8.8.8, 100.64.1.1, 200.10.10.5, 172.16.0.1。正确做法是从右侧开始先裁掉你认识的负载均衡172.16.0.1再裁掉你认识的CDN出口段200.10.10.0/24剩下最后一个100.64.1.1它才是经过可信链路传递下来的第一跳地址可以当作真实用户IP。假如用户伪造了8.8.8.8在最前面因为你的裁剪是从右侧往左的你会先裁掉可信节点最后停在100.64.1.1那个8.8.8.8反而不会被采纳。这个机制就叫按信任边界裁剪。2. 动手之前先解读请求头字段语义是解析的灵魂2.1 四类头部字段的分工与优先级这里涉及的字段不多但语义容易混我用一个表格把它们讲清楚。字段作用格式示例可靠性REMOTE_ADDRTCP连接对端地址172.16.0.1高但只是最后一跳X-Forwarded-For记录整条链路经过的节点IP客户端在最左100.64.1.1, 200.10.10.5低用户可伪造前缀X-Real-IP通常由入口转发服务设置为真实客户端IP100.64.1.1中取决于谁设置的Forwarded标准化头携带for、by、proto等信息for100.64.1.1;protohttp中新协议逐步推广实际生产环境里X-Real-IP比X-Forwarded-For要干净得多因为它只保存一个IP没有链式拼接。很多CDN和负载均衡配置里会用X-Real-IP来传递用户IP。但缺点也很明显它只能记录入口服务认为的那个IP如果用户伪造X-Real-IP而入口服务又没有覆盖它伪造值就会原样传到后端。Forwarded这个标准头在RFC 7239里有定义支持for、by、proto、host但实际普及率不高很多老服务不识别。我的建议是以X-Forwarded-For链式裁剪为主X-Real-IP作为辅助校验Forwarded作为参考REMOTE_ADDR作为兜底。2.2 从最右到最左的链式解析规则既然要走从右往左裁剪的路线第一步就是把X-Forwarded-For按逗号拆分成列表。这个列表的顺序非常讲究[0]是用户最初发出的IP[1]是第一个转发节点[2]是第二个转发节点以此类推。链式解析的规则就一条从列表末尾开始逐个判断IP是否在你配置的可信节点列表里如果在说明这一环是你认识的转发服务继续往左如果遇到一个不在可信列表里的IP它就是真实客户端IP停止。这个规则用一句大白话说凡是你能解释来源的地址都是中间人解释不了的第一个地址才是真正找你办事的人。2.3 代码第一版能正确解析基础链路的Python函数下面先给你一个只使用Python标准库的基础版本不依赖任何第三方库可以直接跑。import ipaddress def parse_xff(xff_value: str): 把 X-Forwarded-For 字符串拆成 IP 列表 if not xff_value or not xff_value.strip(): return [] parts [p.strip() for p in xff_value.split(,)] result [] for p in parts: if not p: continue result.append(p) return result def get_real_ip( remote_addr: str, xff_value: str , trusted_proxiesNone ): 从右往左裁剪 X-Forwarded-For返回可信链路下的第一个IP if trusted_proxies is None: trusted_proxies [] ip_list parse_xff(xff_value) # 从右往左裁剪 for ip_str in reversed(ip_list): ip_obj ipaddress.ip_address(ip_str.split(%)[0]) # 去掉IPv6 scope后缀 if not _is_trusted(ip_obj, trusted_proxies): return str(ip_obj) # 如果整条链都是可信节点说明最后一段的TCP对端就是真实来源 return remote_addr def _is_trusted(ip_obj, trusted_proxies): for net_str in trusted_proxies: try: network ipaddress.ip_network(net_str, strictFalse) if ip_obj in network: return True except ValueError: continue return False这段代码的逻辑顺序很关键先翻转列表从最右开始遇到不在白名单里的IP就返回如果全是白名单那说明真实客户端直接打到你的转发服务上此时用REMOTE_ADDR兜底。注意ip_str.split(%)[0]是为了处理IPv6地址带fe80::1%eth0这类scope后缀的情况。3. 把别人说了算变成自己说了算信任边界与配置化3.1 没有白名单的X-Forwarded-For解析就是裸奔第一版代码虽然能跑但有一个致命问题trusted_proxies如果是空列表那么函数会在遇到第一个非空IP时就返回这个值很可能是用户随手写的伪造头。举个实际例子攻击者构造请求把X-Forwarded-For设成1.1.1.1直接打到你后端。解析函数从最右向左看到1.1.1.1不在白名单立刻返回它。你就把1.1.1.1当成了用户IP风控系统的地域判断、频率限制全部被带偏。所以生产环境里的白名单必须配全。你至少要把CDN节点的回源IP段负载均衡的内网IP段网关服务的IP段任何你主动放置在你与用户之间的转发服务IP段全部写进配置。配置从哪里来一般可以找CDN服务商要到回源IP段列表内网设备则用运维管理平台的网段信息汇总。不要嫌麻烦这个白名单直接决定了你解析结果的上限。3.2 进阶代码按可信节点段递归裁剪下面是升级版我把它封装成一个类支持从JSON或配置加载可信段并提供解析方法。这版代码已经在我自己好几个项目里跑过。import ipaddress from typing import List, Optional class RealIPResolver: def __init__(self, trusted_proxies: Optional[List[str]] None): self.trusted_networks [] for net_str in trusted_proxies or []: try: self.trusted_networks.append(ipaddress.ip_network(net_str, strictFalse)) except ValueError: continue def add_trusted_proxy(self, net_str: str): try: self.trusted_networks.append(ipaddress.ip_network(net_str, strictFalse)) except ValueError: pass def _is_trusted(self, ip_obj) - bool: for network in self.trusted_networks: if ip_obj in network: return True return False def resolve(self, remote_addr: str, xff_value: str ) - str: ip_list self._parse_xff(xff_value) for ip_str in reversed(ip_list): raw ip_str.split(%)[0] try: ip_obj ipaddress.ip_address(raw) except ValueError: # 遇到脏数据跳过继续往左找 continue if not self._is_trusted(ip_obj): return str(ip_obj) return remote_addr staticmethod def _parse_xff(xff_value: str) - List[str]: if not xff_value or not xff_value.strip(): return [] parts [p.strip() for p in xff_value.split(,)] return [p for p in parts if p] resolver RealIPResolver(trusted_proxies[ 200.10.10.0/24, # CDN回源段 172.16.0.0/12, # 内部负载均衡段 10.0.0.0/8, # 网关段 ]) real_ip resolver.resolve( remote_addr10.0.0.5, xff_value8.8.8.8, 100.64.1.1, 200.10.10.5, ) print(real_ip) # 100.64.1.1这个过程里有个容易被忽略的细节当X-Forwarded-For里出现非IP脏数据时ipaddress.ip_address会抛出ValueError代码里我选择跳过继续往左找。这样即使某个节点配置错了、追了一个脏值也不会让整个解析崩溃至少还能从后续可信链路里找到可用的IP。3.3 伪造与异常检测链式顺序不对就是信号代码能解析只是第一步你还需要有能力识别对方在撒谎。以下几种特征出现时建议你标记异常并做特殊处理异常特征说明应对链中出现非IP字符串攻击者塞入abc、null等脏数据跳过但记录告警链中IP个数远大于预期正常CDNLb最多3~5个出现10个以上可能被拼接截断只保留最右可信部分最左侧IP是内网保留段说明用户伪造了内网地址丢弃边缘值按可信裁剪结果为准XFF中IP与REMOTE_ADDR完全一致说明可能没有经过转发或者被刻意构造二次校验TCP层对端IPv6格式被截断或丢弃例如2001:db8::1被拆成两个字段用ipaddress验证完整合法性把这些检测写成函数并不难重要的是你检测到之后怎么办。我的经验是异常样本不要直接丢弃而是单独存到一个ip_parse_anomaly日志里定期看。因为攻击者的工具经常迭代今天注入8.8.8.8明天可能注入127.0.0.1你在异常日志里能提前看到新花样。3.4 从解析到产出一个完整的IP提取工具类把上一节的能力合并起来就是一个相对完整的工具类。import ipaddress import re from typing import List, Optional, Tuple class ClientIPResolver: def __init__(self, trusted_proxies: List[str], max_chain: int 5): self.trusted_networks [ipaddress.ip_network(n, strictFalse) for n in trusted_proxies if _valid_net(n)] self.max_chain max_chain self.anomaly_count 0 def resolve(self, remote_addr: str, headers: dict) - Tuple[Optional[str], List[str]]: anomaly_msgs [] xff headers.get(X-Forwarded-For, ) or headers.get(x-forwarded-for, ) or ip_list [p.strip() for p in xff.split(,) if p.strip()] if len(ip_list) self.max_chain: anomaly_msgs.append(fchain_too_long:{len(ip_list)}) ip_list ip_list[-self.max_chain:] if not ip_list: return remote_addr, anomaly_msgs for ip_str in reversed(ip_list): raw ip_str.split(%)[0].strip() try: ip_obj ipaddress.ip_address(raw) except ValueError: anomaly_msgs.append(finvalid_ip:{raw}) continue if not self._is_trusted(ip_obj): return str(ip_obj), anomaly_msgs return remote_addr, anomaly_msgs def _is_trusted(self, ip_obj) - bool: return any(ip_obj in net for net in self.trusted_networks) def _valid_net(n: str) - bool: try: ipaddress.ip_network(n, strictFalse) return True except ValueError: return False这里我加了max_chain上限防止攻击者塞一个几千个IP的巨型头来拖慢解析。对大多数架构来说一条链路不会超过5跳。超过上限的截断后再继续解析也省得正则匹配反复消耗CPU。你把这个ClientIPResolver初始化一次放在服务进程里每个请求调用resolve方法即可整条链路解析耗时大概在微秒级对业务无感。4. 信息泄露检测请求头里藏着你不该暴露的东西4.1 转发痕迹的泄露面内网IP、服务类型、内部域名很多团队做IP解析只关注拿到用户IP却忘了自己的服务也在向外输出内部信息。转发链路里容易泄露的信息主要有三类内网IP段Via: 1.1 gateway-10-0-0-6.example.internal直接把内部IP和域名写进响应头。服务类型Server: nginx/1.18.0、X-Powered-By: Express把中间件版本裸奔出来。节点标识有的网关会在自定义头里写节点ID比如X-Served-By: gw-az1-001这在链路被外部看到时等于暴露了你内部有AZ维度。对后端应用来说最危险的是把内网IP段直接出现在HTTP响应头或日志里。攻击者拿到内网段就知道该扫描哪个网段寻找入口。4.2 用Python扫描请求头中的内部信息模式你可以在Nginx层统一清洗也可以在应用里加一个中间件做检测。这里给一个简单的扫描函数把可疑头都揪出来。import re INTERNAL_IP_PAT re.compile( r((10\.\d{1,3}\.\d{1,3}\.\d{1,3})| r(172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3})| r(192\.168\.\d{1,3}\.\d{1,3})) ) INTERNAL_DOMAIN_PAT re.compile(r(\.internal|\.local|\.corp|\.lan)\b, re.I) SENSITIVE_HEADERS [Server, X-Powered-By, Via, X-Served-By, X-Backend-Host] def scan_response_headers(headers: dict): findings [] for name, value in headers.items(): if INTERNAL_IP_PAT.search(value): findings.append({header: name, type: internal_ip, value: value}) if INTERNAL_DOMAIN_PAT.search(value): findings.append({header: name, type: internal_domain, value: value}) if name in SENSITIVE_HEADERS: findings.append({header: name, type: info_leak, value: value}) return findings把这段接到你的网关响应中间件里线上跑一阵你会看到不少惊喜有的服务长年在Server头里挂着完整版本号有的在Via里泄露了内网主机名。这类问题属于不查不知道一查吓一跳。4.3 被动防御统一出口头策略与日志脱敏扫描出来了就要治理。最省事的办法是在Nginx或网关处统一设置出口头server_tokens off; proxy_hide_header X-Powered-By; proxy_hide_header Via; add_header Server webserver;对应到应用层你可以在Python响应中间件里把敏感头统一覆盖或者删除。日志侧也别闲着记录的IP、请求头凡是包含内网段的字段都做一次脱敏。最简单的脱敏函数是把IP的中间段替换成*def mask_ip(ip_str: str) - str: try: ip_obj ipaddress.ip_address(ip_str) except ValueError: return invalid if ip_obj.version 4: parts ip_str.split(.) return f{parts[0]}.{parts[1]}.*.* else: return ipv6_masked:*:*日志里保留完整IP和脱敏后的IP两个字段排查问题时看完整版本对外展示和统计时用脱敏版本。别嫌麻烦等哪天真要出数据给第三方你就知道这个习惯有多重要。4.4 主动验证构造一次性请求模拟链路最后我推荐一个我常用的验证方法直接写一个Python小脚本把请求头改成一条模拟链路发到自己的接口检查解析结果是否符合预期。import requests # 模拟经过CDN和负载均衡后的请求头 headers { X-Forwarded-For: 100.64.1.1, 200.10.10.5, 172.16.0.1, X-Real-IP: 100.64.1.1, Via: 1.1 cdn-node-1 } resp requests.get(https://your-api.example.com/debug, headersheaders, timeout5) print(resp.text)在接口的调试路由里把你解析工具输出的IP和REMOTE_ADDR一起返回。如果脚本里的XFF链尾是172.16.0.1而你把它配进了可信段那么接口返回的用户IP应该是100.64.1.1。如果返回了200.10.10.5说明可信段没生效或者链路顺序理解反了。这种主动构造的方式比你在线上翻日志试错高效得多。5. 我在线上踩过的三个坑给后来者的实弹经验5.1 坑一IPv6地址被按逗号拆分解析直接崩第一次上线这套解析逻辑时我没有用ipaddress校验而是直接用split(,)然后取第一个字段。结果遇到一条X-Forwarded-For: 2001:db8:0:1:1:1:1:1, 172.16.0.1我的代码把IPv6地址按逗号分开了以为第一个就是用户IP可它其实是IPv6地址本身没错问题出在后面的逻辑我把第一个字段当作合法IP去白名单判断IPv6地址当然不在白名单里于是返回了它。遇到这种数据看起来解析成功实际取到的是转发节点的IPv6地址用户真实的IPv4地址被丢了。正确做法就是前文代码里的先用ipaddress.ip_address做完整合法性验证再参与白名单判断。IPv6字符串里还可能带%interface后缀也要记得先用split(%)[0]清理。5.2 坑二CDN回源请求被误当成用户请求某段时间我发现某个地域的登录频率统计暴涨后来才定位到CDN节点回源时会携带一组特殊的内部标记而我的白名单配置漏掉了CDN回源段的一个新段位于是解析函数把这个回源段IP当成了真实用户IP。所有回源请求的IP都集中在这一个段里看起来就像某个地区的用户数量突然翻倍。这种坑很隐蔽排查思路是把解析结果按IP维度统计如果发现某个IP段请求量异常高且归属成某CDN服务商基本可以认定白名单漏配了。另外我不建议把第三方CDN的段全部信任而是只信任你实际购买的那个服务商的回源段这样即使其他请求伪造了CDN段也不会被放过。5.3 坑三大小写撕裂了判断逻辑HTTP头字段名是不区分大小写的但我在代码里用了字典直接查X-Forwarded-For没做小写归一化。线上有一个网关服务发出的头是小写x-forwarded-for另一个服务发的是标准大小写同一个请求经过不同网关后后端有的请求能解析到IP有的拿不到排查了一下午才发现是大小写问题。现在我的工具类统一先把所有头转成lower()再查找再也不犯这个错。5.4 推荐的分层落地方案网关清洗、逻辑层解析、存储层脱敏把这几段经验汇总一下我的最终方案分三层网关层在Nginx或网关服务上用real_ip_from、real_ip_header等指令先做一次IP归一化把可信链外的多余头直接清掉同时隐藏Server、Via等泄露头。应用层用上面写的ClientIPResolver工具解析真实客户端IP所有接口统一使用这一个工具不各写各的。存储层写日志时保留完整IP用于排查同步写一份脱敏后的IP用于统计、报表、对外展示。这三层各管一段网关负责清洗应用负责解析存储负责脱敏职责清楚出问题时也容易定位。你如果现在正被日志里那一串IP整得头大不妨先从第一件事做起把你认识的转发节点IP段整理出来写进可信白名单然后跑一版解析工具对比一下之前用的土办法大概率会发现自己原来丢了不小的比例。多出来的这部分差异化数据可能就是被你误判的真实用户。