
你是不是也遇到过这种场景后端接口明明打印的是request.getRemoteAddr()结果日志里清一色全是 Nginx 的内网 IP或者辛辛苦苦从X-Forwarded-For里取了第一个 IP上线没两天就被别人伪造数据刷得怀疑人生。在 Spring Boot 项目里获取真实客户端 IP看着是个烂大街的小问题但放到真实部署环境下前面有 Nginx、有多级反向代理、有云负载均衡背后的门道一点都不少。这篇文章我会从根因讲起把“IP 为什么不对”拆清楚再给出我在实际项目中验证过的一套落地实现覆盖工具类写法、过滤器接入、Nginx 与网关联动、常见坑位排查。不管你是刚写 Spring Boot 的新手还是被线上日志折磨过的老手照着做基本能覆盖 90% 以上的生产场景。1. 为什么你拿到的 IP 总是“不对”1.1 一个看似简单却天天踩坑的需求登录审计要记录 IP风控要按 IP 限流黑名单要按 IP 拦截运营报表要按 IP 统计地域这些需求在任何一个 Web 项目里都是刚需。Spring Boot 里默认提供了一套 Servlet 接口HttpServletRequest.getRemoteAddr()看起来就是为“拿客户端地址”准备的于是很多人第一版代码都是这样写的String ip request.getRemoteAddr();本地开发环境跑一下确实能拿到127.0.0.1感觉没问题。直到应用部署到测试环境前面挂了一台 Nginx日志里突然全变成了192.168.1.10、10.0.0.8这种内网地址才意识到事情没那么简单。更麻烦的是有人听说要读X-Forwarded-For头于是改成“取第一个 IP”。这个方向是对的但忽略了“这个头可以被调用方伪造”的问题。如果 Nginx 没有做覆盖处理任何人带一个X-Forwarded-For: 8.8.8.8的请求头你的系统就会把8.8.8.8当成他的真实 IP风控和黑名单在这种数据面前基本形同虚设。1.2 根因请求链路里多了“中间人”要理解为什么会踩坑先明确一个基础概念getRemoteAddr()返回的不是“发起请求的浏览器 IP”而是“与你服务器建立 TCP 连接的那一端 IP”。没有中间层时这一端就是客户端本机所以能拿到真实 IP。可一旦前面有 Nginx、HAProxy、Spring Cloud Gateway、云负载均衡等任意一层代理服务器实际建立的连接对象是代理节点而不是真正的客户端。可以把这个过程类比成去公司前台找某个人。直接打电话给对方来电号码就是他的真实号码。但如果所有电话先经过前台总机转接你这边“来电显示”看到的永远是总机号码。X-Forwarded-For这类请求头就相当于总机在便签上帮你写了一句“这个电话原本是张三打来的”。不同链路下getRemoteAddr()和请求头的情况大致如下部署链路getRemoteAddr() 结果X-Forwarded-For 结果浏览器直连应用真实客户端 IP通常为空Nginx 反向代理一层Nginx 服务器 IP仅含真实客户端 IP客户端 → CDN → Nginx → 应用Nginx 服务器 IP客户端 IP, CDN 节点 IP应用自带负载均衡组件负载均衡节点 IP依配置而定可能为空技术方案选型的第一步永远是搞清楚自己部署环境里到底有几层代理。很多人犯的错就是拿本地开发的经验去套生产环境这就像用厂家的说明书正常开机却忽略了自己这台机器还外接了 UPS、稳压器和排插。1.3 请求头并不是绝对可信这里必须把话说透X-Forwarded-For本质上是 HTTP 头客户端理论上可以随便设置。标准 HTTP 协议并没有规定代理必须清洗这个头如果业务系统只是简单读了第一个值那等于是在用自己的信任给不怀好意的人开门。行业内处理这个问题的通用原则是“入口清洗、下游信任”。由最外层代理节点负责覆盖或者追加真实客户端 IP应用层只信任这个入口写入的值。如果最外层 Nginx 配置写的是proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;那么 Nginx 会把客户端请求里已有的 XFF 追加上$remote_addr最终变成伪造IP, 客户端真实IP。应用层如果直接取第一个还是会中招。正确做法见后面的工具类实现但不能只看工具类入口配置必须一起改。2. 核心原理X-Forwarded-For 到底该怎么读2.1 头格式与语义X-Forwarded-For的标准格式是逗号分隔的 IP 列表最左侧是原始客户端每经过一层代理就往右追加一个代理节点 IPX-Forwarded-For: client, proxy1, proxy2举个例子客户端 IP 是203.0.113.5经过一层 NginxIP 是192.168.1.10转发到应用应用收到的值是X-Forwarded-For: 203.0.113.5Nginx 的$proxy_add_x_forwarded_for会把客户端本来带的 XFF 和$remote_addr拼接起来。如果客户端自己带了一个X-Forwarded-For: 1.1.1.1那么应用收到的就是X-Forwarded-For: 1.1.1.1, 203.0.113.5除了 XFF常见的还有两个头X-Real-IPNginx 单独设置的客户端 IP一般配proxy_set_header X-Real-IP $remote_addrForwardedRFC 7239 定义的标准头格式更规范但普及率远不如前两者。很多 Spring Boot 项目里X-Real-IP和X-Forwarded-For会同时出现。XFF 的价值在于完整记录链路X-Real-IP 的价值在于简单直接。到底优先读哪个要看团队的 Nginx 规范。如果两边配置都正确读哪个都行如果两边都没配那读哪个都是空。2.2 正确解析顺序先说一个常见误区不少人以为 XFF 里 IP 个数就等于代理层数有几层就取第几个。这个说法只适用于“每一层代理都诚实追加”的理想环境而且假设客户端没有伪造头。一旦遇到客户端主动带 XFF 的情况按层数取位基本必错。从安全角度讲正确的解析思路是从右往左找第一个“不是代理”的 IP。因为右侧的 IP 是离服务端最近的真实链路数据越往右可信度越高。具体操作是先拿 XFF 按逗号拆分成数组从数组尾部开始向左遍历跳过unknown、跳过配置的可信代理 IP通常就是公司内网 Nginx/网关 IP遇到的第一个非代理 IP 就是客户端真实 IP。这个思路听起来复杂落地时可以根据项目情况做简化。如果应用入口只有一层 Nginx而且 Nginx 已经统一覆盖了 XFF那直接取最左侧也没问题。关键是你要清楚自己的链路在哪一档。怕伪造就按右往左找不在乎只求能拿到那简单取第一个也无不可。只是数据安全这种事我建议还是按最坏情况来设计。2.3 安全防护思路在实际项目里“绝对正确”的 IP 其实不存在我们只能尽量逼近真实。完整的防护思路包含三个层次入口覆盖最外层网关必须清洗 XFF不能允许客户端随意传递。Nginx 推荐用$remote_addr直接覆盖而不是$proxy_add_x_forwarded_for追加。如果还要保留原始链路可以另存一个头。白名单过滤应用层维护可信代理 IP 列表解析 XFF 时从右往左跳过可信 IP反向找到第一个真实客户端。兜底降级如果 XFF 为空或者全是unknown退回getRemoteAddr()并发日志告警提示网关配置可能缺失。第 3 部分给出的工具类会体现“从右往左找 内网网段跳过”的思路但真正在生产环境上线前一定要先和运维确认入口层的覆盖规则工具类只是最后一道保险。3. Spring Boot 中的完整落地实现3.1 配置层开箱策略不可少Spring Boot 从 2.2 版本开始引入了server.forward-headers-strategy配置用来控制内嵌容器对Forwarded、X-Forwarded-*等头的处理方式。可选值有两个none不做处理getRemoteAddr()返回代理地址native由容器解析比如 Tomcat 会使用自身机制处理转发头frameworkSpring 框架层解析通过ForwardedHeaderFilter把转发头信息应用到请求上。从 Spring Boot 2.2 到 3.x默认值其实是native。这个配置生效之后request.getRemoteAddr()可能直接返回客户端 IP看起来一切都变好了。但注意它并不能一劳永逸替代你自己写的 XFF 解析逻辑。原因有二第一native策略解析的是标准Forwarded头Nginx 默认只设 XFF 和 X-Real-IP不一定生成标准 Forwarded 头。两者行为存在差异。 第二它解决的是“RemoteAddr 的值不对”的问题不解决“XFF 被伪造”的问题。风控、审计这种对数据可信度要求高的场景还是要自己控制解析逻辑。所以我的建议是这个配置可以开但开完之后把getRemoteAddr()当作兜底而不是唯一来源。server: forward-headers-strategy: framework tomcat: remoteip: remote-ip-header: x-forwarded-for protocol-header: x-forwarded-protoframework模式下 Spring 会更具主动性地处理转发头开发环境下调试也直观。生产环境如果前面还有多层代理远程 IP 内部处理的正则白名单不够用仍然需要后面的工具类做二次解析。3.2 写一个正经的 IP 工具类这是整套方案的核心。我在项目中沉淀过一个ClientIpUtil思路就是“先链路口头再 header最后兜底”并且对 XFF 采用“从右向左找第一个非内网 IP”的策略。public class ClientIpUtil { private static final String UNKNOWN unknown; public static String getClientIp(HttpServletRequest request) { String ip getIpFromXff(request); if (isValidIp(ip)) { return ip; } ip getIpFromHeader(request, X-Real-IP); if (isValidIp(ip)) { return ip; } ip request.getRemoteAddr(); return formatIp(ip); } private static String getIpFromXff(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (!StringUtils.hasText(xff) || UNKNOWN.equalsIgnoreCase(xff)) { return null; } String[] ips xff.split(,); // 从右往左找第一个非内网IP防止客户端伪造左侧地址 for (int i ips.length - 1; i 0; i--) { String ip ips[i].trim(); if (!UNKNOWN.equalsIgnoreCase(ip) !isInternalIp(ip)) { return formatIp(ip); } } // 全链路都是内网IP说明代理链上全是内网转发取最左侧 return formatIp(ips[0].trim()); } private static String getIpFromHeader(HttpServletRequest request, String headerName) { String ip request.getHeader(headerName); if (StringUtils.hasText(ip) !UNKNOWN.equalsIgnoreCase(ip)) { return formatIp(ip.trim()); } return null; } private static boolean isValidIp(String ip) { return StringUtils.hasText(ip) !UNKNOWN.equalsIgnoreCase(ip); } private static boolean isInternalIp(String ip) { if (ip null || ip.isEmpty()) { return true; } if (ip.startsWith(10.) || ip.startsWith(192.168.)) { return true; } if (ip.startsWith(172.)) { int second parseSecondSegment(ip); return second 16 second 31; } return ip.startsWith(127.) || ip.startsWith(0:) || ::1.equals(ip); } private static int parseSecondSegment(String ip) { try { String withoutPrefix ip.substring(4); int dotIndex withoutPrefix.indexOf(.); String seg dotIndex 0 ? withoutPrefix.substring(0, dotIndex) : withoutPrefix; return Integer.parseInt(seg); } catch (Exception e) { return -1; } } private static String formatIp(String ip) { if (ip null) { return ; } // 兼容IPv4映射的IPv6地址 if (ip.startsWith(::ffff:)) { ip ip.substring(7); } // Tomcat在IPv6环境下常用0:0:0:0:0:0:0:1表示本机回环 if (0:0:0:0:0:0:0:1.equals(ip) || ::1.equals(ip)) { ip 127.0.0.1; } return ip; } }这里有几个设计点值得解释一下。第一段先解析 XFF用“从右往左找非内网 IP”的算法。这个选择是针对多级代理场景设计的如果项目明确只有一层反代且入口已经覆盖 XFF直接取第一个会更简单。第二段读X-Real-IP是作为 XFF 缺失时的补充开发环境直连应用时这个头大概率也是空的所以最后落到getRemoteAddr()。最后formatIp做的事很细但生产环境特别需要。你部署到云服务器上用浏览器直连时拿到0:0:0:0:0:0:0:1这种 IP日志里看着不像127.0.0.1直观在 IPv6 环境下还可能拿到::ffff:192.168.1.1这种带前缀的 IPv4 映射地址在 IP 库匹配地域时经常会踩坑统一处理掉能省很多后续麻烦。3.3 用过滤器一次性拿到 IP避免到处重复工具类写好了但如果每个接口都自己去调那还是太啰嗦。更推荐的做法是在 Filter 层做一次统一提取放到 request attribute 里后面所有业务方法都能取到顺便还能塞进日志系统。Component public class ClientIpFilter extends OncePerRequestFilter { private static final String CLIENT_IP_ATTR clientIp; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String clientIp ClientIpUtil.getClientIp(request); request.setAttribute(CLIENT_IP_ATTR, clientIp); // 同时放入MDC方便日志框架直接打印 MDC.put(CLIENT_IP_ATTR, clientIp); try { filterChain.doFilter(request, response); } finally { MDC.remove(CLIENT_IP_ATTR); } } }为什么用OncePerRequestFilter而不是直接实现Filter因为 Spring 内置的一些转发机制会导致同一个请求被过滤器执行多次OncePerRequestFilter能保证一次请求只执行一次这是 Spring 框架给我们的便利没必要自己去写标记逻辑。把 IP 放进 MDC 算是一个顺手的额外收益。日志框架里只要配置%X{clientIp}每行日志都能自动带上客户端 IP排查故障时在线上一看就知道请求从哪来不用每行日志都要往上翻链路。这个习惯我建议所有后端项目都养起来。3.4 接口里怎么用在 Controller 中不需要依赖HttpServletRequest也能取到 IP直接从 request attribute 获取RestController public class ClientIpController { GetMapping(/api/client-ip) public MapString, String getClientIp(HttpServletRequest request) { Object clientIp request.getAttribute(clientIp); String ip clientIp ! null ? clientIp.toString() : unknown; return Collections.singletonMap(clientIp, ip); } }但注意过滤器生效顺序。如果你的项目里还有全局异常处理器、拦截器要确保ClientIpFilter在它们之前或者至少不晚于它们执行。可以使用Order(Ordered.HIGHEST_PRECEDENCE)把它放到最前面这样后续所有组件都能拿到 IP。Component Order(Ordered.HIGHEST_PRECEDENCE) public class ClientIpFilter extends OncePerRequestFilter { ... }这里有一个很容易被忽略的小点如果某个接口路径在 Spring Security 的过滤链里被提前拦住了而你又是基于ClientIpFilter取 IP 做白名单那么过滤器注册顺序和 Security 过滤器链的顺序必然要一起设计。不同的过滤器执行顺序直接决定你拿到的到底是谁写的 IP。别问我为什么提这个生产环境里因为这个顺序问题排查了两天的经历说多了都是泪。4. 网关与部署环境的联动配置4.1 Nginx 侧头信息配置服务端代码写得再好入口 Nginx 不配合也是白搭。Nginx 配置有三个常见写法含义差别很大# 方案A仅设置X-Real-IP proxy_set_header X-Real-IP $remote_addr; # 方案B追加客户端的XFF proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 方案C直接用remote_addr覆盖XFF proxy_set_header X-Forwarded-For $remote_addr;方案 B 是网上最常见的模板但它保留了客户端自定义的 XFF 前缀如果客户端传入伪造X-Forwarded-For: 1.1.1.1应用收到的 XFF 是1.1.1.1, 客户端真实IP工具类用“从右往左找”还能救回来但如果你直接取第一个就翻车了。方案 C 是我在安全性要求稍高的项目里推荐的做法。直接用$remote_addr覆盖 XFF彻底丢弃客户端带来的任何 XFF 内容让应用层拿到的 XFF 永远是最干净的。如果还想记录原始链路头用于排障可以单独设一个自定义头proxy_set_header X-FF-Original $http_x_forwarded_for;Nginx 日志里就能对照着看客户端到底传了什么东西进来。这个技巧对排查“到底是谁在伪造 IP”非常有用强烈建议保留。4.2 Spring Cloud Gateway 场景如果网关不是 Nginx而是 Spring Cloud Gateway处理逻辑就变成在过滤器里 mutate 请求头。Configuration public class GatewayClientIpConfig { Bean public GlobalFilter clientIpGlobalFilter() { return (exchange, chain) - { String clientIp exchange.getRequest().getRemoteAddress() ! null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : ; ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-Real-IP, clientIp) .header(X-Forwarded-For, clientIp) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); }; } }这里有一点要留意exchange.getRequest().getRemoteAddress()在 Gateway 里返回的是与当前实例建立连接的远程地址如果在 Gateway 前面已经挂了负载均衡或者另一层网关这里拿到的也未必是真正的浏览器 IP。所以 Gateway 自身的定位要明确看它是不是最外层入口。如果它前面还有 Nginx那这里的玩法应该改成“透传已有 XFF 并追加当前连接来源”而不是直接覆盖。4.3 Tomcat RemoteIpValve 参考有些老项目会通过 Tomcat 的RemoteIpValve来处理转发头Spring Boot 中可以这样配置server: tomcat: remoteip: remote-ip-header: x-forwarded-for protocol-header: x-forwarded-proto internal-proxies: 10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}配置生效后Tomcat 内部逻辑会把匹配internal-proxies的代理 IP 当作可信来源request.getRemoteAddr()会返回最接近客户端的 IP。这能解决基础场景但要注意internal-proxies 的正则如果写得太宽会把公网 IP 也当成内网代理反而取不到真实 IP如果还同时在 Spring Boot 配置里开了forward-headers-strategyframework两边都在处理转发头可能产生重复处理或互相冲突建议二选一。如果项目组没有专门运维能把控 Tomcat 层配置我的建议是尽量走应用层工具类少依赖容器功能。容器方案看着配置少但出了问题排查链路很深对排障并不友好。5. 常见问题与排查技巧实录5.1 开发环境拿到 0:0:0:0:0:0:0:1本地直接用 IDE 启动 Spring Boot然后浏览器访问日志里经常出现0:0:0:0:0:0:0:1。这是因为本机访问同时满足 IPv6 回环和 IPv4 回环而 Tomcat 在某些系统环境下优先返回 IPv6 形式。处理方法有两种。一是工具类里做统一格式化把它转成127.0.0.1上面代码里已经写了二是调 JVM 参数-Djava.net.preferIPv4Stacktrue但这是全局生效不推荐只在本地调试项目里乱加。推荐前者因为生产环境拿到的 IPv4-mapped 地址也可能长这样统一处理不亏。5.2 日志里全是内网 IP怎么定位是第几层丢的遇到这种情况首先确认一个简单问题你实际部署链路到底是什么不确认链路就改代码大概率是瞎调。我排障时的顺序是临时写一个 debug 接口把 XFF、X-Real-IP、RemoteAddr 全部打印出来从浏览器请求到 Nginx 访问日志再到应用日志逐层看 IP 值的演化找到“IP 从这里开始变成内网”的那一层基本就是问题源头如果是第二层代理没有透传上一层的真实 IP那么问题出在那一层的 proxy_set_header 配置上。这个排查路径实测下来非常有效。大多数项目的问题根本不是代码逻辑而是某一层代理漏配了头信息。代码写得再对入口没存真实 IP后面全是猜。5.3 XFF 被伪造风控数据被刷前面提到过XFF 可以被客户端任意伪造尤其是在没有最外层清洗的情况下。生产环境验证中我见过最典型的情况是客户端带X-Forwarded-For: 8.8.8.8应用直接取第一个值黑名单直接失效。要彻底解决核心思路只有一条在入口侧统一覆盖 XFF应用侧可以信任。像 Nginx 里用$remote_addr覆盖就是最直接有效的手段。如果公司网关体系复杂还要配合“从右往左找非内网 IP”的解析规则形成双保险。另外遇到 CDN 时要注意CDN 厂商有自己的回源头规则比如Ali-CDN-Real-IP、X-Cdn-Src-IP等得和运维确认清楚到底哪个头是 CDN 节点写入的真实客户端 IP。不同厂商头名称不同想要通用方案最好先确认 CNAME 链路走到哪一层。5.4 排查速查表现象可能原因处理建议日志中全是 127.0.0.1应用与客户端同机属正常现象无需处理但注意日志字段口径日志中是 Nginx 内网 IP缺少 XFF 配置或 Spring 策略未开配置 Nginx 头并开启forward-headers-strategyXFF 取了第一个但值不对客户端伪造头入口覆盖 XFF取右侧非内网 IP拿到 ::ffff:192.168.1.1IPv4-mapped IPv6 地址工具类统一前缀去除网关和 Tomcat 策略冲突两边同时处理转发头只保留一层处理机制本机能拿到真实 IP线上不行本地无代理线上有代理以线上链路为准本地只验证逻辑这张表建议直接贴在项目 Wiki 里团队里任何一个人遇到同类问题不用重新踩一遍坑。最后再说一点我的个人体会。IP 获取这件事真正难的不是写代码而是“链路意识”。同一个 Spring Boot 项目在不同公司、不同云环境下部署拿到正确 IP 的方案细节可能完全不一样。与其背一个所谓的终极方案不如把原理弄清楚再结合自己项目的部署链路做裁剪。我现在的习惯是把这个工具类抽成一个公共包所有服务统一引用入口清洗规则也做成公司级规范这样无论谁接新项目IP 问题都不会再成为线上事故的源头。