
业务刚接入Nginx反代那几天我翻Java服务日志发现所有用户IP都是127.0.0.1。后来好不容易调到能看到一串10.10.x.x的内网地址可这些照旧不是访客的真实IP。这个场景在nginx代理模式下特别典型Java侧一不留神拿到的就是代理节点地址而不是客户端真实IP地址。搞不定它登录风控、频控、地域统计这些功能全都会跟着失真运营拿着日志来问你“为什么今天全是内网IP”你还没法一句话解释清楚。这篇文章就把这个问题完整拆一遍从Nginx代理后为什么IP会变到header怎么配、Java代码怎么写再到安全和多级代理场景下的坑最后是生产环境的验证方法。你要是维护的业务也挂在Nginx后面无论前端是Spring Boot、SSH传统框架还是WAR包部署的Tomcat这套思路都能直接套用。1. 问题真相getRemoteAddr()拿到的只是“最后一个对端IP”1.1 先看一次典型的“IP全丢”事故假设你部署了这样一套环境客户端浏览器 → Nginx公网入口 → 后端Java服务。用户正常访问接口后后端记录日志的代码大概是这个套路String clientIp request.getRemoteAddr(); log.info(用户访问来自IP{}, clientIp);上线第一天没问题因为当时Java服务还直接暴露在公网没有Nginx。等统一把入口收敛到Nginx之后日志里就开始出现两类诡异的IP如果Nginx和Java在同一台机器日志满屏是127.0.0.1如果Nginx在独立机器上日志里是Nginx服务器的内网IP比如10.0.0.15如果Java服务跑在Docker里则经常是172.17.0.1这种Docker网桥网关。这些IP有一个共同特征它们都不是真实访客的公网地址。更麻烦的是登录接口的IP风控也随之失效——攻击者不管从哪来后端眼里都是同一台Nginx的IP封IP封了个寂寞。1.2 HTTP代理的原罪TCP连接在Nginx这里被“换头”了要理解为什么会这样得先弄清Servlet获取IP的原理。HttpServletRequest.getRemoteAddr()返回的其实是当前HTTP请求对应的TCP连接对端地址。HTTP请求要先建立TCP连接才能发送而这个连接是从哪台机器建立的getRemoteAddr()就拿哪台机器的IP。在没有代理时浏览器直接和Java服务的端口建立TCP连接对端就是用户的公网出口IP所以取到的IP没问题。但有了Nginx反向代理后请求链路变成了两条TCP连接客户端 → Nginx这条连接上Nginx看到的$remote_addr是客户端IPNginx → Java服务这条连接上Java服务看到的getRemoteAddr()是Nginx的IP。也就是说Nginx在中间充当了“中转站”把客户端连接掐断再自己新建一条连接转发到后端。Java服务拿到的TCP对端地址理所当然就是Nginx所在机器的IP。这跟编程语言无关Java、PHP、Python、Go只要走代理底层都是这个逻辑。HTTP协议本身没有一个强制标准来告诉后端“原始客户端IP是谁”真正被广泛接受的做法是在代理转发时把原始IP写进自定义的HTTP头里。于是就有了X-Forwarded-For、X-Real-IP这些非标准但实际通用的header。Nginx要做的事就是在proxy_set_header里把这些头补齐Java要做的事就是从这些头里把IP解析出来。两个环节缺一不可。2. Nginx转发头配置先想清楚是覆盖还是追加2.1 最常见的基础配置长这样如果你的部署就是简简单单的一层Nginx反代那配置其实非常少。以Spring Boot内嵌Tomcat为例Nginx核心配置段可以写成location /api/ { proxy_pass http://backend_java_cluster; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; }这里最关键的是两行proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;$remote_addr是Nginx当前TCP连接上的对端地址。在一层代理场景下这个值就是真实客户端IP。X-Real-IP是Nginx生态里常用的头直接给后端传真实客户端IP语义清晰。X-Forwarded-For是事实标准头一般用来记录完整的代理链路取的值通常是一个按逗号分隔的IP列表。注意在这里写的是$remote_addr而不是$proxy_add_x_forwarded_for。这两个的区别很关键放到下一节细说。2.2 $proxy_add_x_forwarded_for到底比$remote_addr“聪明”在哪很多教程会写这样一行proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;$proxy_add_x_forwarded_for这个变量名有点误导它并不是一个“帮你把IP加进去”的魔法算子它的实际含义是如果请求头里已经有X-Forwarded-For就取原值然后以逗号分隔追加当前$remote_addr如果原来没有这个头就直接取$remote_addr。举个例子。客户端真实IP是203.0.113.7请求到达Nginx时带了这样一个恶意头X-Forwarded-For: 198.51.100.1此时用$proxy_add_x_forwarded_for设置转发头后端收到的值是198.51.100.1, 203.0.113.7而用$remote_addr设置转发头后端收到的是203.0.113.7看出来了吗$proxy_add_x_forwarded_for会保留客户端传入的X-Forwarded-For并继续往后传。这种设计在多层代理之间是合理的——因为每一层代理都需要把自己的IP追加到列表里才能完整记录链路。但它有一个致命弱点入口处如果有客户端伪造了X-Forwarded-For这个伪造值会被一路带到后端。单层代理场景下你根本不需要保留客户端传上来的头所以用$remote_addr直接覆盖是更安全的做法。那什么时候非用$proxy_add_x_forwarded_for不可答案是当你的入口前面还有一层可信代理的时候。2.3 多级代理链路下的正确配置思路真实生产环境里访问链往往不止一跳。常见结构是客户端 - 云负载均衡/SLB - Nginx - Java服务或者客户端 - CDN - Nginx - Java服务这时如果Nginx还固执地用$remote_addr去覆盖X-Forwarded-For那传给Java的只能是云SLB或CDN节点的IP真实客户端IP反而丢了。正确做法是分两层来看最外层入口代理也就是直接面对用户的那一跳应当承担“清洗XFF”的职责直接覆盖# 入口层Nginx/SLB丢弃客户端带来的任意X-Forwarded-For只写入真实对端 proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr;内层Nginx也就是继续往后返回给Java的那一跳需要保留入口传来的IP同时追加自己的$remote_addr# 内层Nginx保留入口传来的XFF链再追加本机看到的对端IP proxy_set_header X-Forwarded-For $http_x_forwarded_for, $remote_addr; proxy_set_header X-Real-IP $http_x_forwarded_for;用$http_x_forwarded_for是Nginx里获取“当前请求头中XFF原始值”的标准写法效果和$proxy_add_x_forwarded_for基本一致。这样配置之后Java后端收到的XFF长这样用户真实IP, SLB内网IP, Nginx内网IP后面Java侧再从这条链里做可信代理过滤提取出真正属于用户的那一个IP。再说一遍单层代理直接用$remote_addr覆盖多层代理必须在入口清洗、在内部继续追加。很多生产事故都是因为不加区分地照搬$proxy_add_x_forwarded_for结果一个伪造头轻松穿透到后端。3. Java侧怎么读一个带过滤逻辑的真实IP提取工具类3.1 先看懂X-Forwarded-For的值长什么样到了Java这边选择就多了。但在动手写代码之前得先看透X-Forwarded-For这个头里的数据形态。它可能是单层代理203.0.113.7多层代理203.0.113.7, 10.0.0.5, 172.16.1.2被伪造198.51.100.1, 203.0.113.7出现unknownunknown, 203.0.113.7包含IPv62001:db8::1, 10.0.0.5字符串解析并不难难在“到底取哪个IP才算真实客户端”。多数项目的约定是取第一个合法IP因为按前面说的代理追加格式最左边元素应当是最初发起请求的客户端地址。但这只在入口可信、没人伪造的情况下成立。如果你还没有解决伪造问题那这里的“取第一个IP”就是风险点下一章会讲可信代理过滤。3.2 可直接复制的基础工具类这段时间我一直在用下面这套方法覆盖了单层和大多数多层代理场景。核心逻辑是优先读X-Forwarded-For里的第一个合法IP读不到再读X-Real-IP最后兜底到getRemoteAddr()。package com.example.web.util; import jakarta.servlet.http.HttpServletRequest; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.regex.Pattern; public final class ClientIpUtils { private static final String UNKNOWN unknown; private static final Pattern IPV4_PATTERN Pattern.compile( ^((25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)$ ); private ClientIpUtils() { } public static String getClientIp(HttpServletRequest request) { String forwarded request.getHeader(X-Forwarded-For); if (hasText(forwarded)) { String[] candidates forwarded.split(,); for (String candidate : candidates) { String ip candidate.trim(); if (isValidIp(ip) !UNKNOWN.equalsIgnoreCase(ip)) { return ip; } } } String realIp request.getHeader(X-Real-IP); if (hasText(realIp) isValidIp(realIp.trim())) { return realIp.trim(); } return request.getRemoteAddr(); } public static boolean isValidIp(String ip) { if (ip null || ip.isBlank()) { return false; } ip ip.trim(); if (IPV4_PATTERN.matcher(ip).matches()) { return true; } if (ip.contains(:)) { try { InetAddress address InetAddress.getByName(ip); return address ! null; } catch (UnknownHostException e) { return false; } } return false; } private static boolean hasText(String str) { return str ! null !str.isBlank(); } }这段代码在请求入口过滤器中调用一次就行比如Spring的OncePerRequestFilterComponent public class ClientIpFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception { String clientIp ClientIpUtils.getClientIp(request); MDC.put(clientIp, clientIp); try { filterChain.doFilter(request, response); } finally { MDC.remove(clientIp); } } }把IP塞进MDC之后业务代码里要用直接用MDC.get(clientIp)就行不用反复解析请求头也能保证整个请求链路里日志打的IP口径一致。3.3 IPv6、unknown和边界值的兜底处理这个工具类有几个设计细节值得说明。第一处理unknown。很多老代理会在解析不到IP时往XFF里写入unknown如果代码不拦这五个字符就会被当成非法IP传给下游。所以我解析候选值时先校验是不是unknown再校验是不是合法IP只要不是就跳过继续往后找。第二IPv4的话用正则就能快速判断但IPv6压缩格式、IPv4映射IPv6这些情况手写正则不现实。所以我用InetAddress.getByName()做二次校验这个方法对纯IP字符串不会触发DNS查询只是完成地址解析效率可以接受。这里也可以直接换Guava的InetAddresses.isInetAddress()更稳但多一个依赖按团队习惯选就行。第三注意日志格式。IPv6地址本身带冒号X-Forwarded-For里是用逗号分隔的所以“按逗号切分”这个逻辑对IPv6仍然成立。但是如果你把原始IP直接放进日志或者HTTP头再往外传最好用中括号括起来比如[2001:db8::1]不然后面写Nginx正则或者做IP段匹配时会绕不少弯路。第四getRemoteAddr()这个兜底不只是摆设。服务直连公网或者处于纯内网调试环境时没有Nginx没有转发头那Socket对端就是真实客户端直接返回它完全没毛病。4. 别天真地信X-Forwarded-For伪造头、可信代理与容器级过滤4.1 客户端想伪造这个头比改URL还简单HTTP头完全由客户端控制这是一个被太多人忽略的事实。攻击者只需要把请求头加上一行X-Forwarded-For: 8.8.8.8如果后端无脑信任“取第一个IP”那所有风控、封禁、白名单都会把这个请求判定为来自8.8.8.8。更荒谬的是攻击者可以每次请求都换一个IP等于在过你们的IP防护时穿了一件“隐身衣”。这就是为什么前面强调单层Nginx配置里千万不要用$proxy_add_x_forwarded_for去保留客户端传上来的XFF直接用$remote_addr覆盖是最省心的一层清洗。只有当你确认入口之上还有可信代理时才需要保留并追加同时把“防伪造”的责任转移到后端过滤逻辑里去。4.2 可信代理过滤从右往左跳过内网节点真正稳妥的方案是不管XFF里塞了多少个IP都先把它看成一个代理链路。要找到真实客户端IP应该从最右侧开始一直向左遍历跳过所有属于你内部可信代理的IP遇到的第一个非可信IP才是客户端地址。为什么是从右往左因为XFF的生成规则是每经过一层代理就往右追加IP。最右侧一定是离Java最近的那一跳代理向左依次是更上层的代理最左侧如果正常才是客户端地址。所以跳过所有你认识的内网代理节点后剩下的第一个陌生IP就是客户端。这个逻辑用Java手写也不复杂但Tomcat提供了现成的阀门组件叫RemoteIpValve。它正是干这件事的。在Tomcat的server.xml中配置Valve classNameorg.apache.catalina.valves.RemoteIpValve internalProxies127\.0\.0\.1|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3} remoteIpHeaderx-forwarded-for protocolHeaderx-forwarded-proto /internalProxies是一个正则表达式用来匹配哪些IP属于内部可信代理。启用阀门后Tomcat会在请求进入Servlet之前自动解析XFF把request.getRemoteAddr()改写为穿透可信代理之后的真实客户端IP。这样你的业务代码甚至不需要动getRemoteAddr()拿到的就是正确结果日志里也是对的。如果你用Spring Boot内嵌Tomcat不需要手工改server.xml直接配置这些属性即可server.forward-headers-strategynative server.tomcat.remoteip.internal-proxies127\.0\.0\.1|10\.\d{1,3}\.\d{1,3}\.\d{1,3} server.tomcat.remoteip.remote-ip-headerx-forwarded-for注意server.forward-headers-strategynative的意思是让Tomcat容器自己处理转发头正是为了让RemoteIpValve接管IP解析。如果你设置成framework那就是让Spring的ForwardedHeaderFilter来管两条路子不要混用否则可能出现IP被解析两次、日志里出现两个版本的情况。4.3 手写可信过滤时要注意什么某些不用Tomcat、或者想完全自己控制逻辑的项目手写可信代理过滤也不难。核心就是在上一章工具类的基础上增加一个“可信代理列表”参数public static String getClientIpFromTrustedChain(HttpServletRequest request, SetString trustedProxies) { String forwarded request.getHeader(X-Forwarded-For); if (hasText(forwarded)) { String[] candidates forwarded.split(,); for (int i candidates.length - 1; i 0; i--) { String ip candidates[i].trim(); if (!isValidIp(ip)) { continue; } if (!trustedProxies.contains(ip)) { return ip; } } } return request.getRemoteAddr(); }这里的trustedProxies建议从配置中心或环境变量注入不要硬编码在代码里。因为代理节点IP一变你还要重新发版这显然不能接受。还有一个容易被忽略的细节当请求直连Java服务、没有走任何代理时XFF头可能不存在也可能被人为伪造。如果业务上确实存在“某些服务直接暴露某些服务走Nginx”这种混合架构就不能简单信赖XFF最好通过来源端口、URL前缀或者独立网关去区分请求是从哪个入口进来的再决定该不该信这个头。5. 验证放到生产我用curl和日志确认配置真的生效5.1 三步自查法先看Nginx再看Java最后看链路配置改完了不能凭感觉说“应该没问题”。我一般按下面三步做生产验证。第一步确认Nginx侧看到的是真实客户端IP。手动登录Nginx机器看访问日志tail -f /var/log/nginx/access.logNginx默认日志的$remote_addr字段如果前置没有其他代理那就必须是用户的出口IP。如果这里已经变成SLB内网IP说明你前面还有一层代理后面所有XFF处理都要按多层逻辑来而不是简单在Nginx里写$remote_addr覆盖。第二步写一个临时调试接口把请求相关的所有信息都回显出来RestController RequestMapping(/debug) public class DebugController { GetMapping(/ip) public MapString, String ip(HttpServletRequest request) { MapString, String result new LinkedHashMap(); result.put(remoteAddr, request.getRemoteAddr()); result.put(X-Forwarded-For, request.getHeader(X-Forwarded-For)); result.put(X-Real-IP, request.getHeader(X-Real-IP)); result.put(parsedClientIp, ClientIpUtils.getClientIp(request)); return result; } }然后从一台公网机器上请求这个接口对比输出结果。正常情况下parsedClientIp应该等于你自己的公网出口IP而不是内网段地址。第三步模拟伪造头攻击确认防护生效curl -H X-Forwarded-For: 198.51.100.1 https://api.example.com/debug/ip返回值里的X-Forwarded-For如果还是198.51.100.1开头并且parsedClientIp也变成了这个伪造IP说明当前配置存在被伪造的风险。应该按照上一章的入口清洗或可信代理过滤方案去加固。5.2 日志里的常见异常表现和定位方向下面是这几次排查中遇到过的典型现象整理成了一张表方便对号入座现象可能原因处理方向日志里全是127.0.0.1Nginx与Java同机但Java直接调getRemoteAddrNginx未设置转发头在Nginx补X-Real-IP和X-Forwarded-ForJava改用工具类解析日志里全是Docker网桥IPJava容器通过Nginx容器转发$remote_addr是Docker网关梳理容器网络在Nginx层用$remote_addr传真实客户端IPXFF存在但Java取到最后一个内网IP代码按“取最后一个IP”的逻辑解析改为取第一个合法IP或使用可信代理过滤XFF第一个IP经常变化且能绕过IP限制客户端伪造XFFNginx用了$proxy_add_x_forwarded_for保留原值入口清洗XFF或后端加可信代理过滤IPv6请求日志出现0:0:0:0:0:ffff:a-b-c-dIPv4映射IPv6的格式没有做转换在解析工具类里识别并转换为传统IPv4格式请求走通但X-Real-IP始终为空Nginx配置的proxy_set_header不在实际执行的location块内检查请求实际命中的location确认配置文件生效范围5.3 一次真实排障的回放前阵子帮一个团队排查类似问题现象是登录日志里的IP全是127.0.0.1。排查过程很典型先看Nginx配置发现proxy_set_header X-Real-IP $remote_addr写在了server块但实际请求走的是一个独立location按Nginx配置继承规则location里没有显式写就继承了server层的设置。看起来没问题。可是再看Java端Spring Boot内嵌Tomcat代码里直接用了request.getRemoteAddr()压根没有读X-Real-IP。于是Java拿到的是Tomcat和Nginx建立TCP连接的对端地址也就是127.0.0.1。后来改成在过滤器里调用ClientIpUtils.getClientIp(request)日志立刻正常了。这个案例看着简单但它提醒我一个经验Nginx配置得再对Java侧不去读对应的头等于白配Java代码写得再好Nginx不把IP放进转发头也取不到。两个环节必须同时落地。5.4 几条越早明白越好的原则最后说几句这些年在生产里攒下来的体会。日志里的IP一定要从请求入口统一解析一次然后放到MDC里后续不管打几行日志都用同一个值否则排查问题时会看到同一个请求出现两个IP平白增加沟通成本。配置优先级上能用容器级方案就用容器级方案。RemoteIpValve能在底层把getRemoteAddr()洗干净业务代码和框架日志几乎不用改这比到处手动调用工具类更不容易漏。只有那些确定需要自定义解析规则的场景再用手写工具类也不迟。至于XFF本身它终究只是一个请求头可信度必须建立在“入口清洗干净”和“可信代理过滤正确”这两条前提之上。你越早把这两件事想清楚后面被伪造头坑的可能性就越小。先让Nginx把好第一道关再让Java在最后一公里做干净这套组合到今天依然是最稳的做法。