首页 / 资讯中心 / 文章详情

Spring Boot获取真实IP全攻略:从X-Forwarded-For到多级代理与安全加固

Spring Boot获取真实IP全攻略:从X-Forwarded-For到多级代理与安全加固 ★ FEATURED ARTICLE
1. 为什么你的Spring Boot拿到的IP总是不对1.1 从一次真实故障说起日志里的IP全是内网地址先讲一个我去年遇到过的情况。有个项目的生产环境突然需要按IP维度做限流开发直接把request.getRemoteAddr()拿来用结果限流规则一上线就把自己人全封了。查日志发现所有请求的来源IP都长这样192.168.1.105 172.20.0.3 10.244.2.17全是内网地址。看了一眼部署架构就明白了前端是NginxNginx后面还挂了一层K8s的Service转发应用拿到的remoteAddr是上一层转发组件的Pod IP跟真实用户八竿子打不着。这是99%的人做错的第一步——没有搞清楚应用看到的IP和真实客户端IP之间的链路到底有几层。真正等你排查的时候会发现还有更麻烦的情况。比如在服务器上用InetAddress.getLocalHost().getHostAddress()去拿本机IP拿到的往往是容器IP而不是服务器公网IP这个信息不但没用还会把你往沟里带。我见过不少人在这一步浪费大量时间试图从本机地址里推断出客户端地址方向完全偏了。先说结论获取真实客户端IP这件事90%的工作不是写代码而是理清楚你的请求从客户端到应用服务器之间经历了哪些代理层。只有搞清楚每一层对请求头做了什么改动才能判断该信任哪个值。这一篇我会从问题根源、常用方案、多级代理、自研过滤器、安全加固、验证方法这几个维度把Spring Boot获取真实IP这件事彻底讲透。1.2 部署链路画清楚少走一半弯路我建议任何人在排查IP问题之前先画一张链路图不用多细致就标注清楚客户端 - [CDN] - Nginx - Spring Boot。画完这张图你就知道该信任哪一层、该读哪个头、该配哪个参数。最常见的部署链路有两种链路A没有CDN客户端直连Nginx客户端 - Nginx - Spring Boot这种场景下X-Forwarded-For一般只有两段客户端IP和Nginx的IP如果Nginx配置了proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for。取第一个就行了。链路B有CDN客户端经过CDN再进Nginx客户端 - CDN - Nginx - Spring Boot这种场景下X-Forwarded-For至少有三段。而且CDN可能会在头里加入自己的标识。如果应用直接取第一个IP拿到的是CDN的节点IP不是客户端的IP。我在生产环境就遇到过这种情况客户端IP怎么调都显示某运营商的节点IP应用层怎么改都没用。最后发现是取头逻辑写错了第一个IP是CDN的出口IP客户端的真实IP在第二位。把取值逻辑从取第一个改成从右往左跳过可信代理IP之后问题立刻消失。所以核心问题不是Spring Boot怎么读IP而是你的链路里哪一层是可信的、哪一层是不可信的。这个判断不做一切读取逻辑都是靠猜。2. 入门级方案HttpServletRequest拿IP的正确姿势2.1 请求头到底该怎么读顺序很重要先说最基础的一步。Spring Boot的HttpServletRequest里能拿到客户端的相关信息request.getRemoteAddr()拿到的是直接和你应用建立TCP连接的那一方IP。没有代理时是客户端IP有代理时是代理服务器的IP。request.getHeader(X-Forwarded-For)标准转发头每经过一级代理代理都会追加一个IP。request.getHeader(X-Real-IP)Nginx里配置的真实IP头通常只经过一级代理时使用。request.getHeader(X-Forwarded-Host)记录原始Host的有些团队会用来做域名判断这里不多展开。正确的读取顺序是先取X-Forwarded-For从右往左跳过所有可信的代理IP第一个不可信IP就是真实IP。如果没有这个头再用X-Real-IP最后才用remoteAddr。很多教程上来就教取XFF的第一个IP这个说法在单级代理下没问题但只要有CDN就是错的。原因在于CDN节点IP对应用来说是不可信的它不在可信列表里取第一个时就把CDN节点IP当成了客户端IP。提示如果你只有一个代理层且代理层不会伪造这个头可以简单取第一个IP。否则必须从右往左取第一个非可信IP。2.2 说了无数遍的坑伪造X-Forwarded-For这是我见过的最大隐患。X-Forwarded-For是HTTP头客户端发送请求时完全可以自己伪造。也就是说攻击者可以直接在请求里带X-Forwarded-For: 1.2.3.4如果你的应用直接信任第一个IP就会被骗。我在做安全日志分析时经常看到某个IP被大量扫描一查日志里的客户端IP发现是伪造的。真正有价值的做法是需要过滤掉不可信的直连IP比如运营商的机房IP段。从右往左取XFF时必须跳过信任的代理IP而不是取第一个。应用层要做IpUtils之类的工具类统一处理不能每个接口自己写一遍。真做安全风控的系统一般不会只依赖IP头。还会结合User-Agent、Cookie、设备指纹等。但IP字段至少不能被伪造。2.3 取第一个还是取最右取决于信任模型我画个简单的场景场景一没有CDN、只有一层Nginxproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;。这时XFF长这样X-Forwarded-For: 1.2.3.4, 192.168.1.10第一个IP是真实客户端IP取第一个即可前提是客户端无法直接连接应用。场景二有CDN链路变成客户端 - CDN - Nginx - 应用X-Forwarded-For: 1.2.3.4, 45.67.89.10, 192.168.1.10第一个IP仍然是客户端的真实IP但前提是CDN不会追加自己的IP。事实上很多CDN会追加。从右往左跳过 Nginx 内网IP192.168.1.10、CDN出口IP45.67.89.10剩下的就是客户端IP1.2.3.4。所以核心逻辑永远是先把不可信代理的IP识别出来然后从右往左取第一个不在可信列表里的IP。这才是终极方案的正确姿势。3. 进阶方案利用Spring Boot内置的ForwardedHeaderFilter3.1 别自己造轮子框架已经帮你做了Spring Boot对转发头有内建支持。只要配置了server.forward-headers-strategyframeworkSpring Boot会启用ForwardedHeaderFilter自动读取X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host等头并覆盖request.getRemoteAddr()和request.getScheme()等的返回值。这意味着应用层代码基本不用改原来调用request.getRemoteAddr()的地方拿到的可能就是真实客户端IP了。前提是代理层必须设置正确的转发头。应用配置了forward-headers-strategyframework。应用部署在代理后面且代理是可信的。我在实际项目里用这个方案比较多因为改动小、统一性强。但它有个隐含前提代理头必须是可信的。如果客户端可以直连应用或者代理层没有过滤外部伪造的XFF头那ForwardedHeaderFilter反而会放大伪造风险。3.2 配置方式application.yml里面的关键项Spring Boot 2.2版本的配置如下server: forward-headers-strategy: framework tomcat: remoteip: protocol-header: X-Forwarded-Proto remote-ip-header: X-Forwarded-For internal-proxies: 10\\.0\\.0\\.0/8|192\\.168\\.0\\.0/16|127\\.0\\.0\\.1其中forward-headers-strategy: framework开启框架级转发头处理。remote-ip-header: X-Forwarded-For指定从哪个头取客户端IP。internal-proxies正则表达式指定哪些代理IP是可信的默认信任10/8、172.16/12、192.168/16等内网网段。protocol-header当代理是HTTPS时用它来修正request.getScheme()。注意如果不配置internal-proxies默认信任内网代理IP所以应用必须部署在代理后面不能让外部客户端直连。如果外部客户端直连那这个配置反而会把外部客户端误判为可信代理。3.3 配置了framework之后为什么有时候还拿不到真实IP我遇到过几种情况代理层未设置X-Forwarded-For头。Nginx的proxy_pass默认不设置这个头必须显式配置proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;。代理层设置了但Spring Boot版本太低forward-headers-strategy在旧版本里不生效。代理层到应用之间还有一层别的组件比如K8s的Service转发这层组件是否保留XFF头要看具体网络插件。internal-proxies正则写错导致代理IP不在可信列表里框架不认这个头。遇到配置了还是拿不到的问题建议先抓包或打印原始请求头看XFF到底有没有传到应用。这个判断一分钟就能完成。4. 多级代理场景CDN Nginx Spring Boot的完整拆解4.1 多级代理下的XFF头到底长什么样真实生产环境很少是客户端-应用直连的。我自己的服务器前面跑了CDN后面挂Nginx再到Spring Boot应用。整个链路如下浏览器 - CDN节点 - Nginx - Spring Boot应用请求经过CDN时CDN会在XFF头里追加自己的节点IP。然后这层再转发给Nginx。Nginx再追加本机IP。最终Spring Boot收到的XFF长这样X-Forwarded-For: 客户端IP, CDN节点IP, Nginx内网IP注意这只是一般情况。有的CDN会在XFF前面加客户IP有的会重写有的会额外加自己的私有头。所以生产环境一定要在接入CDN后测试头格式别想当然。我在一次排查中把CDN和Nginx的XFF都打出来才发现CDN的节点IP并不可信因为取第一个IP拿到的是CDN节点。把信任模型改成从右往左跳过可信IP后才正常。4.2 从右往左取第一个非信任IP完整代码这是核心方法。我自己写了一个工具类几乎是每个Spring Boot项目的标配public class IpUtils { private static final Pattern IP_PATTERN Pattern.compile( ^(\\d{1,3}\\.){3}\\d{1,3}$); /** * 从请求中获取真实客户端IP。 * 信任列表中的IP会被视为代理从右往左跳过。 */ public static String getClientIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isEmpty()) { String[] ips xff.split(,); // 从右往左遍历跳过可信代理IP for (int i ips.length - 1; i 0; i--) { String ip ips[i].trim(); if (!isTrustedProxy(ip)) { return ip; } } } String xRealIp request.getHeader(X-Real-IP); if (isValidIp(xRealIp)) { return xRealIp; } return request.getRemoteAddr(); } /** * 简单判断是否是可信代理IP。 * 生产环境建议用CIDR匹配不要用正则里写死IP。 */ private static boolean isTrustedProxy(String ip) { if (ip null || ip.isEmpty()) { return true; } // 内网网段、回环地址、本机地址都视为可信 return ip.startsWith(10.) || ip.startsWith(192.168.) || ip.startsWith(172.) || 127.0.0.1.equals(ip); } }这个工具类只做了一件事从XFF里从右往左找第一个不在可信列表的IP。如果XFF没有则退回X-Real-IP和remoteAddr。几点经验开发环境经常有多个代理比如IDE自带的代理或者内网网关导致IP识别不准。这种环境下最好绕过代理测试或把代理加入可信列表。如果应用部署在K8s里Pod IP、Service IP都不可信真正的客户端IP在XFF里。生产环境可以用ip2region之类的库做IP归属地但识别逻辑仍然是这套。4.3 生产环境为什么不能只靠IP我把话说得直接一点IP能帮你定位但不能帮你定罪。对于风控、封禁、审计这些场景IP只是其中一环。我做过一个登录审计系统单纯按IP计数发现某个IP一天登录了几十万次。后来加了User-Agent和设备指纹才发现那个IP其实是一整个NAT出口背后可能有几千个真实用户。如果只靠IP做封禁会误杀一大批人。所以多级代理场景下正确思路是用上面代码拿到真实客户端IP写入日志。结合User-Agent、Cookie、设备信息等做关联分析。日志字段统一格式方便后续排查。5. 终极方案自研过滤器统一处理不污染业务代码5.1 为什么要自研过滤器直接改代码不行吗直接在Controller里调用request.getRemoteAddr()当然能拿到IP但问题在于每个接口都写一遍代码重复严重。如果哪天换了取IP逻辑比如加了CDN所有接口都要改一遍。日志记录、审计、风控、限流都要用IP分散在各处出了问题很难排查。我倾向于在Filter层面做统一处理。这样Controller里可以直接用RequestUtils.getClientIp(request)或者通过RequestContextHolder获取不需要每个接口自己解析。5.2 过滤器实现一个Filter解决所有IP取值问题下面这个过滤器会把真实IP写入request.setAttribute(realIp, ip)。之后在任何地方都可以通过request.getAttribute(realIp)拿到不用重复解析。Component public class ClientIpFilter implements Filter { Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) servletRequest; String clientIp IpUtils.getClientIp(request); request.setAttribute(CLIENT_IP, clientIp); // 写入MDC方便日志框架直接输出 MDC.put(clientIp, clientIp); try { chain.doFilter(servletRequest, servletResponse); } finally { MDC.remove(clientIp); } } }在logback的pattern里加上%X{clientIp}所有日志行都会自动带上客户端IP排查问题时非常有用pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %X{clientIp} - %msg%n/pattern我自己在日志里加了这一项后排查线上问题效率提升了一个档次。以前要搜IP得翻半天现在所有请求日志都带IP。5.3 过滤器里最容易踩的坑异步请求、文件上传、过滤器顺序异步请求Spring Boot的异步Servlet里Filter可能拿不到原始HttpServletRequest。处理办法是在异步上下文里读取属性或者在进入异步之前就把IP存到上下文中。文件上传multipart/form-data请求不会影响IP获取但请求体解析也是Filter链条里的一个环节过滤器顺序别搞乱否则Body会被提前消费。我遇到过一次过滤器先读了请求体下游解析文件时就拿不到内容了。过滤器顺序要放在CharacterEncodingFilter之后、HiddenHttpMethodFilter之前比较稳妥。过滤器注册Spring Boot里用Component注册的Filter可能会被自动注册到所有URL如果只想对特定路径生效建议用FilterRegistrationBean手动控制。Configuration public class FilterConfig { Bean public FilterRegistrationBeanClientIpFilter clientIpFilter() { FilterRegistrationBeanClientIpFilter registrationBean new FilterRegistrationBean(); registrationBean.setFilter(new ClientIpFilter()); registrationBean.addUrlPatterns(/*); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE 10); return registrationBean; } }setOrder越小越先执行建议放在比较靠前的位置。但别放最前面因为还需要一些框架自己初始化好的上下文。5.4 请求上下文里拿IP不进Controller也能取如果某些逻辑在Service层不经过Controller可以通过RequestContextHolder获取当前请求ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); String clientIp IpUtils.getClientIp(request); }不过需要提醒一下使用RequestContextHolder会让Service层和Web层耦合如果Service层要在非Web环境下复用比如定时任务、MQ消费者就别用它。项目里如果扛不住这种耦合可以把IP作为参数传入Service方法。6. 验证方案与测试方法别等上线了才发现IP不对6.1 本地验证工具和步骤我经常看到有人写好IP解析逻辑本地一测没问题上了生产就出问题。原因很简单本地是直连生产是多级代理。本地验证建议分三步直连测试不配置代理直接请求应用。应该拿到本机IP或127.0.0.1。模拟代理测试用Nginx配置一层代理转发到应用验证XFF的传递。伪造头测试手动在请求里加上X-Forwarded-For: 6.6.6.6看看应用是否信任了这个伪造值。推荐用curl -H模拟curl -H X-Forwarded-For: 6.6.6.6 http://localhost:8080/api/ip如果返回结果里出现了6.6.6.6说明你的解析逻辑信任了外部伪造值需要修正。这个测试非常重要。很多系统被刷IP就是这种原因伪造XFF后风控形同虚设。6.2 生产排查口诀先链路、后代码、再配置我在排查线上IP问题时有一套固定顺序先看链路请求是从哪进来的经过了几层代理。这一步看网络拓扑一分钟。再看代码应用里到底读的哪个头直接看日志看打印的remoteAddr和XFF值。最后看配置forward-headers-strategy有没有设置internal-proxies是否覆盖了代理IP这个顺序很重要我见过不少人在代码里反复调试结果发现是Nginx少了proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;根本不是应用的问题。附上一个排查清单检查项判断标准常见问题链路拓扑是否有CDN、多层Nginx少画了一层代理Nginx配置是否设置proxy_set_header默认不追加XFFCDN配置是否回源时带XFF有的CDN会重写头Spring Boot配置forward-headers-strategy未设置framework解析代码是否从右往左取XFF直接取第一个客户端直连是否可绕过代理访问外部可直接访问应用6.3 别忘了HTTPS和WebSocket这类特殊情况如果应用前面是HTTPSSpring Boot在代理后可能认为协议是HTTP导致某些跳转或Cookie逻辑出错。解决办法有二配置server.forward-headers-strategyframework让框架自动识别X-Forwarded-Proto。Nginx里设置proxy_set_header X-Forwarded-Proto $scheme;。WebSocket场景下IP获取逻辑不变但要注意Nginx必须开启proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;否则握手会失败。IP本身不是WebSocket特有的问题。7. 性能与安全加固从能拿到IP到拿对IP且不被骗7.1 百万级请求下IP解析不要拖后腿IP解析本身很简单一个字符串split和正则匹配开销极小。但如果你在每个接口里都做一遍还做了DNS反查InetAddress.getByName(ip).getHostName()性能就会明显下降。DNS反查是网络IO操作单次几毫秒到几十毫秒高并发下绝对撑不住。我在生产环境做过一次压测加了DNS反查后QPS直接掉了一半。后面把DNS反查去掉只用IP解析和归属地查询性能马上回来。经验IP解析逻辑里永远不要做DNS反查。想记录主机名另外用异步任务处理不要阻塞请求线程。7.2 防止IP伪造配置可信代理列表的进阶用法上面已经说了internal-proxies要配置好。如果条件允许可以把可信代理列表做成配置项按环境区分。比如custom: trusted-proxies: dev: 127\\.0\\.0\\.1|192\\.168\\.0\\.0/16 prod: 10\\.0\\.0\\.0/8|100\\.64\\.0\\.0/10这样开发环境和生产环境可以用不同的信任策略。对于高安全要求的系统还需要在Nginx层就把外部传入的XFF头处理掉或重写。做法是在Nginx配置里重置XFF头location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }$proxy_add_x_forwarded_for会保留客户端传入的XFF值并追加自己的IP如果不想信任客户端传入的值可以强制重写为proxy_set_header X-Forwarded-For $remote_addr;后一种方式会把XFF重置为直连IP伪造头直接失效。7.3 日志与告警把IP值落到链路上拿到IP后日志要统一打。我推荐在Filter里放入MDC然后在logback的pattern里输出。这样不管哪个类打的日志只要请求经过Filter都会带上IP。日志示例如下2024-11-18 10:23:45.678 [http-nio-8080-exec-1] INFO com.example.UserService - 1.2.3.4 - 用户登录成功告警系统也可以基于IP做同一IP在短时间内大量请求5xx、同一IP频繁触发风控规则等。但记住IP是共享的NAT情况多很多恶意IP背后其实是正常用户告警规则要设置阈值和冷静期避免误杀。8. 实战案例复盘一次把默认配置改成framework之后的功能回归8.1 事件背景有一次我给一个项目配置了forward-headers-strategyframework结果首页一直报404。排查了半天发现是ForwardedHeaderFilter把request.getContextPath()或者Host相关逻辑改了导致路由匹配错乱。这个问题很典型开启framework后Spring Boot会信任转发过来的Host和Proto如果你代理层没配好或者应用里基于Host做了判断就会出问题。8.2 排查思路与解决方式排查顺序先恢复默认配置看是否正常。恢复后正常说明问题出在framework上。看日志中的request.getRequestURL()发现Host被X-Forwarded-Host覆盖了。检查Nginx配置发现没有设置proxy_set_header Host $host;导致转发的Host不对。在Nginx补上Host头问题消失。所以开启framework前先确认代理层是否正确设置了Host、X-Forwarded-For、X-Forwarded-Proto。如果不确定先用curl打原始请求头别一股脑切。8.3 从这个案例里提炼出的建议能不开framework就不开。很多项目只需要在Filter里读XFF取IP不必让框架级转发处理介入。如果开了一定要确认代理层的Host、Proto、Port转发头都正确。任何配置改动先小流量验证再全量发布。9. 总结一份可以直接抄的完整配置清单9.1 核心配置基于多级代理的最佳实践组合最稳妥的组合是Nginx设置标准转发头 Spring Boot用自定义Filter解析 日志输出IP。这种方式不依赖framework也能正确处理多级代理风险最小。Nginx端配置server { listen 80; server_name example.com; location / { proxy_pass http://spring-boot-app:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }Spring Boot端Filter和IpUtils两个类配合上文给出的代码即可。如果没有CDN这个组合就是终极方案如果有CDN只需在IpUtils里把CDN出口IP加入可信列表或调整取数逻辑。9.2 各方案对比方便选型方案适用场景优点缺点直接读remoteAddr无代理直连简单有代理时拿不到真实IP读XFF取第一个IP单级代理简单CDN下取到代理IP易伪造ForwardedHeaderFilter单级代理/信任代理改动小信任模型复杂可能影响Host自定义Filter解析XFF多级代理/CDN灵活可控需要自己维护可信IP列表9.3 最后的实战经验根据我这几年的经验IP解析这个事90%的坑都出在链路不明确和信任边界不清晰上。如果你在启动项目前就把网络拓扑画清楚明确每一层代理的可信性后面的代码其实很简单。代码就几行难的是判断。最后再分享一个小技巧在排查IP问题的时候记得把你的测试请求同时打印原始X-Forwarded-For、X-Real-IP和remoteAddr三个值放在一行日志里。对比着看问题常常一眼就暴露了。这个习惯让我少踩了无数坑。
阅读完成 · 觉得有帮助?
咨询建站