一个 Feign 调用引发 403 的问题,排查之后发现是 WAF 在“保护”我
内部服务通过 Feign 调用公网域名,批量查询接口正常工作了很长时间,直到 WAF 上线后突然返回 403。排查发现罪魁祸首是重复的 Query 参数触发了 WAF 的 HTTP 参数污染规则。本文记录了完整的定位过程,并讨论了为什么最终选择用 POST + JSON 来设计批量接口,以及内部服务调用为什么应该和内网绑定。
最近遇到一个不算复杂、但很有代表性的问题。某服务通过 Feign 调用另一个服务的批量查询接口,上线后一直稳定运行,直到有一天运维把 WAF 和防火墙策略开全了,某些接口就开始返回 403 Forbidden。
日志大概是这样的:
feign.FeignException$Forbidden: [403 Forbidden]
during [GET] to [https://api.example.com/api/resource/batch]
奇怪的是,不是所有接口都报错,只有少数几个。而且这几个接口在本地直接 curl 内网地址是通的,通过公网域名访问就报 403。
看一眼出问题的 Feign 接口定义:
@GetMapping("/api/resource/batch")
ResourceResult batch(@RequestParam List<String> resourceIdList);调用的时候,Feign 默认会把 List 参数展开成重复的 Query 字符串:
GET /api/resource/batch?resourceIdList=1001&resourceIdList=1002&resourceIdList=1003服务端 Spring MVC 能正常把这种格式绑定成 List<String>,所以从业务代码角度看,这个请求完全合法。但 WAF 不这么认为。
排查过程
先确认了几个事实:
- 应用日志里没有任何请求记录。说明请求根本没到 Controller 层。
- 网关访问日志能看到这个请求,状态码是 403。
- WAF 日志里有拦截记录,命中规则类型是“HTTP 参数污染”(HPP)。
问题就清晰了:请求在公网入口被安全设备截停了,没进入业务容器。
为了确认是参数格式导致的,我用 curl 分别测了两种形式:
# 重复参数 —— 被拦截
curl "https://api.example.com/api/resource/batch?resourceIdList=1&resourceIdList=2"
# 逗号拼接 —— 通过
curl "https://api.example.com/api/resource/batch?resourceIdList=1,2"结果前者返回 403,后者正常。确认是重复参数触发了 WAF 规则。
链路判断可以画成这样:
为什么 WAF 会拦截重复参数
WAF 的通用规则集里通常会包含对 HTTP 参数污染 的检测。这类攻击的思路是:通过重复提交同名参数,利用不同 Web 服务器、应用框架对重复参数的处理差异来绕过校验或影响业务逻辑。
举个例子,某些网关取第一个值,后端取最后一个值,这种不一致可能被利用。所以安全设备看到重复参数就倾向于先拦下来。
我们的请求恰好长这个样子:
resourceIdList=1001&resourceIdList=1002&resourceIdList=1003
在 WAF 看来,这跟攻击特征太像了。至于为什么其他接口没问题,因为正常的分页查询参数 page=1&size=10 没有重复字段,自然也不会触发这条规则。
怎么解决
这个问题有好几种解法,我按短期到长期的顺序梳理一下。
方案一:把 Feign 调用切到内网
这是最干净的做法。微服务之间的内部调用,本来就不该走公网域名。
项目里早期因为网络架构没到位,很多 Feign 客户端直接配了公网域名 https://api.example.com。后来 Nacos 和内部网络基建完善了,但调用方式一直没改过来。
如果能改成走注册中心发现内网实例,不仅绕开了 WAF,延迟更低,公网暴露面也更小。改动也很直接:
# 之前
api.example.com:
url: https://api.example.com
# 之后(通过服务名调用)
file-service:
url: http://file-service/唯一需要注意的是,有些 Feign 客户端调用的是外部第三方服务,不在 Nacos 里。这类调用不能一刀切,但项目内部服务之间的调用应该优先切回内网。
方案二:WAF 配置精准例外
对于暂时不能切内网的历史接口,可以在 WAF 上配置精确放行:
- 限定具体路径
/api/resource/batch和 HTTP 方法GET - 只放行参数名
resourceIdList的重复 - 限制单次请求最多 100 个 ID
- 先在观察模式(只记录不拦截)验证一段时间,确认没有攻击流量再切换为放行
不要 图省事直接关闭整条 HPP 检测规则,那样会让所有接口都失去这道防护。
方案三:改成逗号分隔传参
如果不想动 WAF,也可以在 Feign 端把集合参数编码成 CSV 格式:
GET /api/resource/batch?resourceIdList=1001,1002,1003Feign 的 @RequestParam 配合 String 类型接收,服务端自己 split 解析,或者用 Spring 的 StringToCollectionConverter 也能处理。
但 CSV 方式有局限:如果 ID 本身包含逗号或特殊字符,解析就容易出问题。只适合数值 ID、短字符串这类简单场景。
方案四:改成 POST + JSON(我最推荐的)
前面几个方案都是“绕”,这个方案是从接口设计层面彻底解决。
把批量查询接口改成 POST,用请求体传递 JSON:
@PostMapping("/api/resource/batch/query")
ResourceResult batch(@Valid @RequestBody ResourceBatchRequest request);请求体长这样:
{
"resourceIdList": [1001, 1002, 1003]
}这种格式语义清晰,扩展性好——以后加字段、加过滤条件都很方便。而且 JSON 数组不会触发任何重复参数的检测规则。
我在项目里最终也是选了这条路。但有个现实问题:这个 GET 接口已经对外提供了,第三方在调用,直接改掉会导致合作方断联。所以实际操作是:
- 保留旧 GET 接口,在 WAF 上做精准放行
- 新增 POST 版本,通过文档和联调逐步引导调用方迁移
- 内部 Feign 调用全部切到 POST 版本
这次排查之后的一些思考
这次的问题让我重新想了几件事。
第一,内部服务调用不要复用公网链路。
内部服务之间的通信,应该走内网或服务网格。公网链路上的安全策略是面向外部攻击的,对内部流量的特征并不友好。把内部流量和外部流量混在一起,安全策略就很难同时兼顾两者,结果往往是互相干扰——要么误拦,要么防护不足。
第二,批量接口的设计要有意识。
以前写接口的时候,GET 用 @RequestParam List<T> 确实很方便,敲几行代码就能跑。但这个习惯不太好。批量查询、批量操作这种场景,天然就适合 POST + JSON,不仅对安全设备友好,对后续扩展也更友好。
应该在项目规范里加一条:数组、集合类参数禁止用在 GET 请求的 Query 中,批量接口统一使用 POST + JSON。当然,对已有接口保持兼容,通过版本化逐步迭代,不搞一刀切。
第三,WAF 日志要会看。
这次排查最快的一步是直接查 WAF 日志,命中规则、请求特征都写得清清楚楚。但如果一开始只盯着应用日志看,会绕很大一圈。遇到 403,排查链路应该是:
WAF/网关日志 → 应用日志 → 代码
而不是反过来。
这个问题的本质其实不是 Feign 的 Bug,也不是 Nacos 配置错误,而是内部服务调用走了不该走的链路 + 接口设计时没有考虑安全设备的处理逻辑。两个因素叠加,就踩坑了。
现在回头看,解决思路其实很清晰:内部调用走内网,批量接口改成 POST。如果你也遇到类似的问题,可以从这两个方向入手,应该能很快定位。