ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot获取客户端IP:从原理到实战,避开代理环境下的那些坑

Spring Boot获取客户端IP:从原理到实战,避开代理环境下的那些坑 1. 从一次线上排查说起为什么获取客户端IP这么“坑”上个月我们线上一个风控接口突然告警日志里大量请求的IP地址都变成了同一个内网IP。这直接导致基于IP的频控策略失效差点让羊毛党钻了空子。紧急排查后发现问题出在我们自以为“万无一失”的获取客户端IP的逻辑上。在传统的单体应用里HttpServletRequest.getRemoteAddr()这个方法基本够用但在今天这个微服务、容器化、负载均衡和CDN满天飞的时代直接用它来获取“客户端真实IP”无异于刻舟求剑。这个标题——“Spring Boot获取客户端的IP地址”——听起来像是个入门级问题网上随便一搜就有几十种代码片段。但恰恰是这种看似简单的问题背后藏着从网络协议、架构部署到安全策略的一整套知识体系。如果你只是拷贝了一段“通用代码”放到项目里那么恭喜你很可能已经埋下了一个时隐时现的Bug。今天我就结合自己踩过的坑和修复过的案例从头到尾拆解一下在Spring Boot乃至任何Java Web项目中如何正确、可靠地获取客户端IP地址。这不仅是一段代码怎么写的问题更是一个关于理解HTTP请求在真实网络中如何流转的问题。2. 理解源头HTTP请求中的IP信息藏在哪在动手写代码之前我们必须搞清楚一个核心问题一个HTTP请求从用户的浏览器或客户端发出到达我们的Spring Boot应用这中间经历了什么IP信息又是如何被传递或修改的2.1 最原始的RemoteAddrHttpServletRequest.getRemoteAddr()返回的是与当前Servlet容器如Tomcat直接建立TCP连接的客户端的IP地址。在最简单的场景下比如你本地用curl直接访问部署在服务器上的应用这个地址就是客户端的真实IP。但是一旦请求前方存在任何“中间件”情况就变了反向代理/负载均衡器 如Nginx、Apache、F5、AWS ALB。这些设备会代表客户端向后端应用服务器发起新的TCP连接。此时getRemoteAddr()拿到的是代理服务器本身的IP通常是内网IP。CDN CDN节点会回源到你的服务器getRemoteAddr()拿到的是CDN节点的IP。WAF/防火墙 同理它们也会作为客户端与你的应用交互。所以在存在代理的网络架构中RemoteAddr失去了代表终端用户的意义它代表的是离你应用最近的那个中间设备的地址。2.2 关键的X-Forwarded-For头为了解决上述问题代理服务器们约定俗成了一个HTTP头部X-Forwarded-For简称XFF。它的设计目的是为了传递请求链路上各个节点的IP信息。其格式通常如下X-Forwarded-For: client, proxy1, proxy2最左边的值client 由第一个代理服务器添加它认为是原始客户端的IP。后续的值 请求每经过一个代理该代理就会将自己的客户端即上一个节点的IP追加到列表的末尾。举个例子用户IP是203.0.113.10请求先经过CDNIP为198.51.100.1再经过公司的Nginx网关IP为10.0.0.1最后到达Spring Boot应用。到达CDN时CDN发现请求头中没有XFF于是添加X-Forwarded-For: 203.0.113.10到达Nginx时Nginx看到已有的XFF头将自己收到的客户端IP即CDN的IP追加到末尾X-Forwarded-For: 203.0.113.10, 198.51.100.1Spring Boot应用最终收到的请求头里X-Forwarded-For: 203.0.113.10, 198.51.100.1而getRemoteAddr()得到的是10.0.0.1。理论上我们只需要取XFF头中最左边的第一个IP就是原始用户IP。但这里有两个大坑头部可被伪造 HTTP头是客户端可以随意修改的。一个恶意用户可以直接在请求中带上X-Forwarded-For: 8.8.8.8来伪造IP。代理链可能不标准 并非所有代理都会正确追加有些可能覆盖、有些可能不添加。2.3 其他相关头部除了XFF还有一些代理服务器会使用其他头部X-Real-IP 通常由第一个代理设置直接设置为它认为的客户端真实IP。比XFF简单但同样存在单点伪造风险。Proxy-Client-IP/WL-Proxy-Client-IP 一些较老的代理如WebLogic插件会使用这些头。HTTP_CLIENT_IP 较少见。在云服务环境中还会有特定的头部例如CloudFront:CloudFront-Viewer-AddressAWS ALB/ELB: 会使用X-Forwarded-For同时可能添加X-Forwarded-Port,X-Forwarded-Proto。核心原则 我们的IP获取逻辑必须与最靠近我们应用的那一层可信代理的配置和行为保持一致。这层代理之外的IP信息对我们来说是不可信的。3. 构建可靠的IP解析工具类理解了原理我们来编写代码。一个健壮的IP获取工具类不能只简单地从某个头里取一个值它需要有一套优先级和验证逻辑。3.1 基础工具方法解析与验证首先我们需要一些辅助方法。import javax.servlet.http.HttpServletRequest; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Arrays; import java.util.List; import java.util.regex.Pattern; public class IpUtil { private static final String UNKNOWN unknown; private static final String LOCALHOST_IPV4 127.0.0.1; private static final String LOCALHOST_IPV6 0:0:0:0:0:0:0:1; private static final String COMMA ,; // 简单的IPV4正则用于基础格式校验 private static final Pattern IPV4_PATTERN Pattern.compile(^(\\d{1,3}\\.){3}\\d{1,3}$); /** * 检查IP是否为“unknown”或空 */ private static boolean isEffective(String ip) { return ip ! null ip.length() 0 !UNKNOWN.equalsIgnoreCase(ip.trim()); } /** * 简单的IPV4格式校验非严格校验 */ private static boolean isValidIpV4(String ip) { if (ip null || !IPV4_PATTERN.matcher(ip).matches()) { return false; } // 简单检查每段数字是否在0-255之间 String[] segments ip.split(\\.); for (String seg : segments) { int num Integer.parseInt(seg); if (num 0 || num 255) { return false; } } return true; } /** * 从X-Forwarded-For等逗号分隔的字符串中提取第一个有效IP */ private static String extractFirstIpFromHeader(String headerValue) { if (!isEffective(headerValue)) { return null; } // 按逗号分割去除空格 String[] ips headerValue.split(COMMA); for (String ip : ips) { String trimmedIp ip.trim(); if (isEffective(trimmedIp) isValidIpV4(trimmedIp)) { return trimmedIp; } } return null; } }3.2 核心获取逻辑优先级与信任边界这是最关键的部分。逻辑的核心思想是从最可信的来源开始尝试依次向后备来源查找。最可信的来源通常是直接与我们约定好的、我们配置的代理。/** * 获取客户端真实IP地址 * 优先级X-Forwarded-For (取第一个) - X-Real-IP - Proxy-Client-IP - WL-Proxy-Client-IP - request.getRemoteAddr() * 注意此方法适用于前方有且仅有一层可信代理如Nginx的情况。 * 如果前方有多层不可信代理此逻辑需要调整。 * * param request HttpServletRequest * return 客户端IP地址 */ public static String getClientIp(HttpServletRequest request) { String ip null; // 1. 尝试从 X-Forwarded-For 获取 String xff request.getHeader(X-Forwarded-For); ip extractFirstIpFromHeader(xff); if (isEffective(ip)) { return ip; } // 2. 尝试从 X-Real-IP 获取 (Nginx常用) String xRealIp request.getHeader(X-Real-IP); if (isEffective(xRealIp) isValidIpV4(xRealIp)) { return xRealIp.trim(); } // 3. 尝试其他一些代理头传统项目可能会遇到 String proxyClientIp request.getHeader(Proxy-Client-IP); if (isEffective(proxyClientIp) isValidIpV4(proxyClientIp)) { return proxyClientIp.trim(); } String wlProxyClientIp request.getHeader(WL-Proxy-Client-IP); if (isEffective(wlProxyClientIp) isValidIpV4(wlProxyClientIp)) { return wlProxyClientIp.trim(); } // 4. 以上都未获取到使用 request.getRemoteAddr() ip request.getRemoteAddr(); if (LOCALHOST_IPV6.equals(ip)) { ip LOCALHOST_IPV4; } // 这里可以添加对RemoteAddr的进一步处理比如判断是否是内网IP等 return ip; }这个getClientIp方法是一个“标准版”实现它假设最靠近应用的那一层代理比如我们自己的Nginx是可信的并且会正确设置X-Forwarded-For或X-Real-IP头。3.3 进阶场景处理多层代理与信任链如果你的应用前方不止一层代理比如用户 - CDN - 云WAF - 自建Nginx - Spring Boot。那么自建Nginx收到的X-Forwarded-For头可能是用户真实IP, CDN IP, 云WAF IP。此时你需要明确信任边界。你只应该信任你自己部署和配置的代理自建Nginx。你不能信任CDN或云WAF传过来的IP因为它们不在你的完全控制之下可能被伪造。一种常见的配置是在自建Nginx这一层清空或覆盖来自上游的X-Forwarded-For头只设置X-Real-IP为自己直接连接的客户端IP即云WAF的IP。这样后端应用始终从X-Real-IP获取一个相对可信的IP至少是你的信任边界内的最后一个节点。如果你的业务必须拿到最原始的IP例如需要根据用户地理IP做风控并且CDN/WAF也是你信任的例如都使用同一家云厂商且有安全保证那么你需要和运维同学确认整个链路的代理配置明确每一层是如何处理XFF头的。然后你的代码可能需要取XFF列表中的第N个IP从右往左数跳过你信任的代理层数。实操心得 在微服务架构下最佳实践是在API网关层统一处理客户端IP的解析和可信化然后将解析出的可信IP作为一个新的、明确的HTTP头例如X-Consumer-Real-IP传递给下游业务服务。下游服务只需读取这个约定的头即可无需再关心复杂的代理逻辑。这实现了关注点分离也提升了安全性。4. 在Spring Boot中集成与应用有了工具类我们在Spring Boot中使用它就非常方便了。这里介绍几种常见的集成方式。4.1 在Controller或Service中直接使用这是最直接的方式在需要IP的地方调用工具类。RestController RequestMapping(/api) public class DemoController { GetMapping(/info) public ResponseEntityMapString, String getInfo(HttpServletRequest request) { String clientIp IpUtil.getClientIp(request); MapString, String result new HashMap(); result.put(clientIp, clientIp); result.put(message, Hello from server!); // 可以将IP用于频控、审计等 log.info(Request from IP: {} for path /api/info, clientIp); return ResponseEntity.ok(result); } }4.2 使用拦截器Interceptor统一处理如果很多接口都需要IP信息或者你想统一进行IP黑名单拦截、请求日志记录使用拦截器是更优雅的选择。Component public class IpInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String clientIp IpUtil.getClientIp(request); // 将IP存入请求属性方便后续Controller或Service获取 request.setAttribute(clientIp, clientIp); // 示例简单的IP黑名单检查 if (isInBlacklist(clientIp)) { log.warn(Blocked request from blacklisted IP: {}, clientIp); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write(Access Denied); return false; } // 记录访问日志可结合MDC log.info(Incoming request. IP: [{}], URI: [{}], clientIp, request.getRequestURI()); return true; } private boolean isInBlacklist(String ip) { // 这里实现你的黑名单逻辑可以从数据库或缓存查询 return false; // 示例 } }然后在配置类中注册这个拦截器Configuration public class WebConfig implements WebMvcConfigurer { Autowired private IpInterceptor ipInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(ipInterceptor) .addPathPatterns(/**) // 拦截所有路径 .excludePathPatterns(/static/**, /error); // 排除静态资源等 } }这样所有匹配的请求在进入Controller之前IP就已经被解析好并存储在request属性中了。4.3 使用过滤器Filter进行更早的拦截过滤器的执行时机比拦截器更早在请求进入Servlet容器后就触发。如果你需要在Spring MVC框架之外进行一些基于IP的操作比如在Spring Security之前过滤器是更好的选择。Component Order(Ordered.HIGHEST_PRECEDENCE) // 设置高优先级尽早执行 public class IpFilter implements Filter { Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) servletRequest; String clientIp IpUtil.getClientIp(request); // 将IP设置到请求中后续拦截器和Controller都能拿到 request.setAttribute(clientIp, clientIp); // 可以在这里做更早期的IP检查比如针对DDoS的非常基础的频控 // ... chain.doFilter(servletRequest, servletResponse); } Override public void init(FilterConfig filterConfig) throws ServletException { // 初始化逻辑 } Override public void destroy() { // 清理逻辑 } }注意事项 过滤器中抛出的异常处理起来比拦截器麻烦一些且对Spring的依赖注入支持不如拦截器直接需要通过其他方式获取Bean。通常对于IP获取和基础日志拦截器已经足够。4.4 与Spring Security集成在安全框架中获取IP也很常见例如用于登录审计、防止暴力破解。Component public class AuthenticationEventListener { private static final Logger log LoggerFactory.getLogger(AuthenticationEventListener.class); EventListener public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) { WebAuthenticationDetails details (WebAuthenticationDetails) event.getAuthentication().getDetails(); String remoteAddress details.getRemoteAddress(); // 注意这里拿到的是RemoteAddr HttpServletRequest request ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String clientIp IpUtil.getClientIp(request); // 使用我们的工具类获取真实IP String username event.getAuthentication().getName(); log.info(User [{}] logged in successfully from IP [{}] (RemoteAddr: {}), username, clientIp, remoteAddress); // 将 clientIp 存入审计日志或用户会话 } }注意WebAuthenticationDetails.getRemoteAddress()获取的同样是HttpServletRequest.getRemoteAddr()在代理环境下不可靠所以我们需要额外从当前请求上下文中获取HttpServletRequest来解析真实IP。5. 生产环境配置与安全加固代码写好了但如果部署环境配置不对一切白搭。这里以最常用的Nginx为例说明如何正确配置。5.1 Nginx反向代理配置这是确保后端能拿到正确IP最关键的一步。你需要在Nginx的location或server块中配置proxy_set_header指令。server { listen 80; server_name your.domain.com; location / { # 将客户端真实IP通过X-Real-IP和X-Forwarded-For传递给后端 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; # 其他代理设置... proxy_pass http://your_springboot_app_upstream; } }$remote_addr Nginx直接连接的客户端IP。对于后端来说这是可信的。$proxy_add_x_forwarded_for 这是一个Nginx变量它会将$remote_addr追加到请求原有的X-Forwarded-For头部后面用逗号分隔。如果请求没有XFF头它就等于$remote_addr。重要安全配置 如果你的Nginx是直接面向公网的最后一层代理那么来自请求的原始X-Forwarded-For头可能是用户伪造的。一个更安全的做法是清空上游传来的XFF头只设置自己的。location / { # 清空从客户端传来的X-Forwarded-For头防止伪造 proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; # ... 其他配置 }5.2 应对IP伪造建立信任代理列表即使配置了Nginx恶意用户如果直接找到你的后端服务地址比如通过某些漏洞泄露还是可以伪造X-Real-IP头。因此后端应用不能无条件信任这些头。一种加固方案是后端应用维护一个“可信代理IP列表”即你的Nginx服务器的内网IP。在解析IP时先检查request.getRemoteAddr()是否在可信列表中。如果在才去解析X-Forwarded-For或X-Real-IP如果不在则直接使用request.getRemoteAddr()作为客户端IP因为请求可能绕过了代理直接打到后端。public class SecureIpUtil extends IpUtil { private static final SetString TRUSTED_PROXY_IPS new HashSet(Arrays.asList(10.0.0.1, 10.0.0.2, 192.168.1.100)); public static String getClientIpSecure(HttpServletRequest request) { String directRemoteAddr request.getRemoteAddr(); // 检查请求是否来自可信代理 if (TRUSTED_PROXY_IPS.contains(directRemoteAddr)) { // 来自可信代理使用标准逻辑解析头部 return getClientIp(request); } else { // 非可信来源直接使用RemoteAddr忽略所有代理头 // 可以记录一条警告日志 log.warn(Received direct request from untrusted IP: {}. Using RemoteAddr as client IP., directRemoteAddr); return directRemoteAddr; } } }5.3 容器化部署Docker/K8s的特殊考量在Kubernetes中Pod前面通常有Service和Ingress Controller如Nginx Ingress。Ingress Controller就扮演了反向代理的角色。Ingress-Nginx Controller 它会自动设置X-Forwarded-For等头。你需要确保Ingress配置正确。通常不需要在后端做特殊处理使用标准的getClientIp方法即可。但要注意如果集群内服务间调用也经过Ingress那么IP可能会是上游服务的Pod IP。Service Mesh如Istio Sidecar代理Envoy会处理这些头部通常会设置X-Forwarded-For和X-Envoy-External-Address等。你需要查阅对应Mesh的文档了解其具体的头部传递策略。一个通用的建议是在K8s环境中将获取客户端IP的逻辑统一放到API网关或Ingress Controller层面然后通过一个确定的、内部约定的HTTP头如X-User-Real-IP传递给业务服务。业务服务只认这个头简化处理逻辑。6. 常见问题排查与调试技巧即使配置了所有东西可能还是会遇到IP不对的情况。下面是一个系统的排查链路。6.1 问题现象获取到的IP全是127.0.0.1或内网IP排查步骤检查请求是否经过代理 直接查看request.getRemoteAddr()。如果它已经是内网IP如172.17.0.1,10.0.x.x说明请求确实经过了代理。检查代理头是否存在 打印所有请求头查看X-Forwarded-For、X-Real-IP等是否存在。EnumerationString headerNames request.getHeaderNames(); while (headerNames.hasMoreElements()) { String name headerNames.nextElement(); log.debug(Header: {} {}, name, request.getHeader(name)); }核对代理配置 如果头部不存在或值不对去检查Nginx等代理的配置确认proxy_set_header指令是否正确设置并且指向了正确的上游你的Spring Boot应用。检查网络拓扑 确认是否有你未知的中间层比如公司的统一出口网关、云服务商的负载均衡器。它们可能修改或清除了头部。6.2 问题现象获取到的IP是伪造的排查步骤确认信任边界 你的应用是否直接暴露在公网如果是任何头部都不可信。必须采用“可信代理列表”方案。检查Nginx安全配置 确认Nginx配置是否使用了proxy_set_header X-Forwarded-For $remote_addr;来覆盖上游传来的可能伪造的头。日志分析 对比访问日志。在Nginx的access_log中记录$remote_addr和$http_x_forwarded_for在后端应用日志中记录解析出的IP。如果两者在可信代理场景下不一致说明头部在传递过程中被篡改。使用TLS/HTTPS 确保从客户端到你的代理Nginx使用HTTPS防止请求在传输途中被拦截篡改。6.3 调试工具与技巧使用curl模拟请求 这是测试代理配置的神器。# 直接访问后端看原始RemoteAddr curl http://backend-server:8080/api/ip # 通过代理访问并手动设置XFF头测试伪造和代理覆盖 curl -H X-Forwarded-For: 8.8.8.8 http://proxy-server/api/ip在代码中输出详细日志 在IP工具类的关键判断点添加DEBUG级别日志记录每一步解析的结果和判断依据。使用网络抓包 在极端复杂的网络环境下可以在代理服务器和后端服务器上使用tcpdump或 Wireshark 抓包直接查看TCP/IP层和HTTP层的原始数据这是最权威的验证手段。7. 性能、缓存与扩展考量当你的应用面临高并发时频繁地解析IP、查询IP归属地或黑名单可能会成为性能瓶颈。7.1 IP地址缓存IP地址本身变化不频繁但解析IP归属地通过GeoIP库或查询IP是否在黑名单中是较慢的操作。可以考虑使用内存缓存如Caffeine来缓存结果。Component public class IpGeoService { Autowired private GeoIPDatabase geoIPDatabase; // 假设的GeoIP查询组件 private CacheString, String ipCountryCache Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(1, TimeUnit.HOURS) // IP归属地相对稳定缓存1小时 .build(); public String getCountryCode(String ip) { return ipCountryCache.get(ip, k - geoIPDatabase.lookupCountry(ip)); } }7.2 在网关层统一处理这是最推荐的扩展方案。将IP解析、黑白名单检查、基础频控、地理信息查询等所有与IP相关的逻辑全部上移到API网关如Spring Cloud Gateway, Kong, NginxLua。好处性能 在网关层拦截非法请求避免流量打到下游业务服务节省资源。一致性 所有服务获取IP的方式统一、简单只需读取网关设置好的头。可维护性 IP相关策略的变更只需在网关一处修改。安全 网关更靠近边缘可以更方便地实施基于IP的WAF规则。例如在Spring Cloud Gateway中你可以编写一个Global FilterComponent public class IpResolveGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String clientIp resolveClientIp(request); // 实现你的IP解析逻辑 // 将解析出的IP放入请求头传递给下游服务 ServerHttpRequest mutatedRequest request.mutate() .header(X-Consumer-Real-IP, clientIp) .build(); // 可以在这里添加IP黑名单检查 if (isBlocked(clientIp)) { exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); return exchange.getResponse().setComplete(); } return chain.filter(exchange.mutate().request(mutatedRequest).build()); } Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } }7.3 IPv6的考虑我们的示例代码主要针对IPv4。现在IPv6越来越普及必须考虑兼容性。正则表达式升级 需要支持IPv6的正则校验IPv6的格式复杂得多。头部处理X-Forwarded-For等头部同样可以包含IPv6地址。解析时需要能识别。数据库存储 确保存储IP地址的数据库字段足够长例如MySQL的VARBINARY(16)或VARCHAR(45)。工具库 考虑使用成熟的网络库来处理IP如Apache Commons Net的InetAddressUtils或者Google的Guava库中的InetAddresses类它们提供了健壮的IP格式验证和转换功能。获取客户端IP这个“小”功能贯穿了从网络基础设施到应用代码的多个层面。它考验的是你对整个请求生命周期的理解。记住几个关键点明确信任边界、与运维同学确认代理配置、代码中实现优先级解析、生产环境做好安全加固。别再简单地拷贝网上的那段代码了根据自己项目的实际架构设计出最适合的IP获取方案才能避免在关键时刻掉链子。
返回列表