线上日志里突然冒出来一堆:
login failed, ip=127.0.0.1
login failed, ip=10.20.4.17
login failed, ip=10.20.4.17
一开始我还以为登录接口的 IP 记录写错了。
翻代码一看:
String ip = request.getRemoteAddr();
代码没毛病。
问题出在前面还有 Nginx、网关、负载均衡。SpringBoot 看到的“客户端”,压根不是用户浏览器,而是最后一跳代理。
这种问题最容易被写成一个IpUtils,然后疯狂判断请求头:
X-Forwarded-For
X-Real-IP
Proxy-Client-IP
WL-Proxy-Client-IP
HTTP_CLIENT_IP
...
我现在看到这种代码第一反应不是佩服兼容性,而是先问一句:
这些 Header,你凭什么信?
因为 Header 是客户端能伪造的。
比如接口直接允许公网访问,我自己发:
curl http://api.example.com/login \
-H "X-Forwarded-For: 8.8.8.8"
你的 Java 如果无脑取X-Forwarded-For第一个 IP,日志里可能真就记成了8.8.8.8。
拿这个 IP 做审计、风控、黑名单,后面基本都白干。
Spring 官方文档也专门提醒过这个问题:应用本身无法判断Forwarded、X-Forwarded-*到底是可信代理加的,还是恶意客户端塞进来的,所以信任边界上的代理应该先清理外部传入的这些 Header。
这才是获取真实 IP 最容易漏掉的一层。
我比较习惯把链路先画出来:
客户端
|
v
负载均衡 / Nginx
|
v
SpringBoot
如果前面只有一层 Nginx,我反而不喜欢搞得太复杂。
Nginx 入口直接重写:
location / {
proxy_pass http://order-service;
proxy_set_header Host $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-Forwarded-For $proxy_add_x_forwarded_for;
不是说它不能用。
而是如果这是公网信任边界,我得先确认前面到底还有没有可信代理。否则客户端自己带一个X-Forwarded-For进来,再一路追加,Java 端取“第一个 IP”,这个洞还是没堵上。
如果链路是:
客户端 -> SLB -> Nginx -> SpringBoot
那就不能粗暴覆盖了。
这时候应该明确:谁是可信代理、哪一层负责清洗 Header、哪一层负责追加代理地址。
这东西必须跟实际网络拓扑一起配,脱离代理链单独写一个万能getClientIp(),我一般不信。
SpringBoot 这边也别急着自己解析字符串。
可以让容器处理转发头:
server:
forward-headers-strategy: native
Spring Boot 当前文档明确说明,如果代理提供常见的X-Forwarded-For和X-Forwarded-Proto,可以使用NATIVE,交给底层 Web Server 原生处理。
Tomcat 背后实际上有RemoteIpValve这一套机制,它会根据代理头和可信代理规则重新确定请求的 remote address。
这样业务代码反而干净:
@Component
public class RequestSource {
public String clientIp(HttpServletRequest request) {
String remote = request.getRemoteAddr();
if (remote == null || remote.isBlank()) {
return "unknown";
}
int scope = remote.indexOf('%');
return scope > 0 ? remote.substring(0, scope) : remote;
}
}
Controller 里直接用:
@PostMapping("/session")
public LoginResult login(HttpServletRequest request,
@RequestBody LoginCommand command) {
String clientIp = requestSource.clientIp(request);
log.info("login request, account={}, clientIp={}",
command.account(), clientIp);
return loginService.execute(command, clientIp);
}
有人可能会问,那我直接这样不行吗?
request.getHeader("X-Forwarded-For").split(",")[0]
能跑。
但我不会把它作为生产环境的最终方案。
假设 Header 是:
X-Forwarded-For: 203.0.113.7, 10.10.2.11, 10.10.5.23
这里至少有三个问题:
客户端能不能伪造第一段?
10.10.2.11是不是公司网关?
10.10.5.23是不是 Nginx?
只看字符串根本回答不了。
所以我排这种问题时,顺序一般是:
先看 request.getRemoteAddr()
确认 SpringBoot 前面到底有几层代理
抓 Nginx / 网关 access log
确认 X-Forwarded-For 实际内容
确定可信代理范围
入口清洗 Header
最后才配置 SpringBoot
不是上来写二十行 Java 判断 Header。
还有一个坑得顺手堵掉:SpringBoot 服务端口最好不要绕过网关直接暴露公网。
否则你前面 Nginx 清洗得再漂亮,攻击者直接请求 8080:
公网 -> SpringBoot:8080
整个信任模型就没了。
防火墙、安全组或者 Kubernetes NetworkPolicy 至少要保证应用只能接收可信入口的流量。
所以这事真正靠谱的方案,不是找到一个“最全的 IP Header”。
而是把这三件事做对:
代理入口清洗伪造 Header,SpringBoot 只信任确定的代理链,业务代码最终只拿处理后的getRemoteAddr()。
真实 IP 从来不是一行 Java 代码的问题。
只要中间多了一层代理,它首先就是个信任问题。