Spring Cloud Gateway网关层XSS过滤全攻略:RequestBody缓存与规则设计
聊到Spring Cloud Gateway网关XSS过滤我脑海里的第一反应不是“怎么写个过滤器”而是早些年一次印象深刻的返工经历。当时我们微服务已经拆了十几个每个服务都自己写了一套XSS过滤逻辑结果某次安全扫描把订单服务的一个反射型XSS漏洞报到我这里的时候发现用户服务那边根本没做任何过滤——各写各的、规则不统一、漏改漏配是常态。后来针对这个问题整体做了收敛把XSS过滤统一上收到网关层情况才彻底改观。这篇文章就把我这次做Spring Cloud Gateway网关XSS过滤的完整思路、核心代码、踩坑记录和上线建议摊开来讲。适合微服务架构下正在做安全治理的后端开发、架构师也适合那种“业务服务几十个、不可能逐个改代码”的实际情况。全文会从为什么非要放在网关层说起到WebFlux请求体的读取姿势、过滤规则怎么设计才不误杀、拦截后怎么跟业务服务衔接、以及上线前必须想清楚的性能和灰度问题。1. 微服务架构下XSS过滤为什么非要放在网关这一层一开始团队里对“过滤放哪一层”是有分歧的。有人觉得前端过滤就够了有人觉得每个服务各写各的过滤也没多大事。现实情况是这两种方案都有硬伤等到线上出问题才后悔。1.1 各层过滤方案的对比与取舍先说前端过滤。页面端对输入框做长度限制、特殊字符屏蔽体验上是必要的但安全上基本等于零。攻击者完全不经过你的页面直接拿curl或者Postman往接口上怼带上script标签一样能进后端。所以前端过滤只能当做用户体验优化不能当安全防线。再说业务服务各自过滤。这种做法的最大问题是治理成本。十几个服务还勉强能靠代码规范约束几十个服务的时候就完全失控了。我这次接手的情况就是典型订单服务过滤了单引号和尖括号用户服务只过滤了script标签商品服务干脆没写过滤器。同一个XSS payload在这个服务被拦截在那个服务就畅通无阻。而且后期规则升级要挨个服务改代码发版改动链路非常长。相比之下网关统一过滤的优势非常明显。Spring Cloud Gateway本身就是所有请求流量的统一入口把安检放这里所有业务服务天然共享同一套防护策略。规则升级只需要改网关、发网关业务服务完全无感知。而且网关过滤还有一个隐藏好处数据到达业务服务之前就已经是“干净”的业务代码里不需要到处做防御性判断逻辑会清爽很多。1.2 网关层过滤的本质优势与不可忽视的代价如果把微服务架构比作一栋楼网关就是唯一的单元门。XSS过滤放在单元门比放在每家每户门口要省事得多——进楼的人先统一做一次安检剩下的问题就是个别房间的特殊要求。Spring Cloud Gateway作为响应式网关在Reactor Netty这一层拦截流量天然具备统一处理的能力这也是我最终选择在网关层做过滤的根本原因。但这个方案不是没有代价。网关是性能敏感位置过滤逻辑每多一次正则匹配、每多一次body解析都会直接体现在接口延迟上。回复式模型下读body又比传统Servlet要麻烦写不好就引发body被读空、下游拿不到参数之类的问题。另外网关过滤属于“一刀切”式的前置校验误杀率控制不好业务方会炸毛。所以我的设计原则从一开始就定了网关XSS过滤是解决问题为主的安全前置目标是“高准确率的粗筛”不是无所不包的终极防线。攻击特征明确的直接拦截可疑但不明确的记录告警放行真正需要保证业务语义的字段单独放行。这个思路在后面规则设计和例外放行里会反复用到。2. 读取请求体从Flow到缓存装饰器的转换网关过滤的第一个技术难点是怎么把body完整地读出来。很多做传统Spring MVC开发的人第一次写Gateway过滤器都会在这个地方栽跟头因为WebFlux的请求体模型跟Servlet完全不是一个套路。2.1 Gateway与WebFlux的Body读取模型Spring Cloud Gateway底层是WebFlux请求体是FluxDataBuffer不是InputStream。DataBuffer是Netty里面的字节容器请求体会被切成一块一块的流式数据。你直接调用getBody()拿到的只是一个响应式流需要自己去订阅、收集、拼接才能还原出完整的body字节数组。这个模型跟传统Servlet的差异我用一个生活化的类比来解释Tomcat里的请求体像是服务员直接端上来的一盘菜你拿筷子想怎么拨就怎么拨WebFlux的请求体则像是中央厨房出来的一条流水线菜是一块一块传过来的你如果想检查整桌菜得先让流水线停下来把所有块拼接齐再动手。网关过滤要做的事就是把流水线上的每一块都接住拼成完整内容检查完再重新放回流水线上。而且更关键的是FluxDataBuffer是单次订阅的。你过滤时读了一遍后面业务转发时如果再去读拿到的是空流。这也是网上很多“网关body日志打印没问题、但业务接口拿不到参数”的根因。解决办法只有一个读出来的body必须缓存住后续任何地方要用都从缓存里取。2.2 缓存请求体并重建请求的GlobalFilter实现我的实现方案是写一个全局过滤器内部用DataBufferUtils.join把body收集成完整字节数组然后通过装饰器模式重写getBody()让下游始终能读到缓存的数据。核心逻辑大概是这样Component public class XssFilterGlobalFilter implements GlobalFilter, Ordered { private static final String CACHED_BODY_KEY XSS_CACHED_REQUEST_BODY; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String contentType request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); // 仅处理包含文本body的请求类型 if (contentType null || contentType.startsWith(multipart/form-data)) { return chain.filter(exchange); } return DataBufferUtils.join(request.getBody()) .map(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); exchange.getAttributes().put(CACHED_BODY_KEY, bytes); return bytes; }) .flatMap(bytes - { String originalBody new String(bytes, StandardCharsets.UTF_8); String filteredBody XssFilter.filter(originalBody); if (!originalBody.equals(filteredBody)) { // 清洗后重建请求体 ServerHttpRequest decorator new CachedBodyServerHttpRequestDecorator( exchange.getRequest(), filteredBody.getBytes(StandardCharsets.UTF_8)); exchange exchange.mutate().request(decorator).build(); } return chain.filter(exchange); }); } Override public int getOrder() { return -1000; } }这里有个细节要注意DataBufferUtils.join会一次性把整个body拉进内存所以必须做大小保护。我的做法是请求头Content-Length超过2MB或者传输编码是chunked且长度未知的直接跳过body过滤只对query参数做检查避免大文件上传或者流式请求把网关内存打爆。装饰器的实现要重写getBody()方法让它返回Flux.just(cachedBytes)对应的DataBufferprivate static class CachedBodyServerHttpRequestDecorator extends ServerHttpRequestDecorator { private final byte[] cachedBody; public CachedBodyServerHttpRequestDecorator(ServerHttpRequest delegate, byte[] cachedBody) { super(delegate); this.cachedBody cachedBody; } Override public FluxDataBuffer getBody() { return Flux.defer(() - { DataBuffer buffer new DefaultDataBufferFactory() .wrap(ByteBuffer.wrap(cachedBody)); return Flux.just(buffer); }); } }2.3 为什么不能用Flux做二次订阅顺带解释一下为什么不能直接写exchange.getRequest().getBody().subscribe(...)。响应式流的规则是单次订阅一次订阅消费完之后数据不会重新产生。Gateway转发给下游服务的时候Netty的request body已经是一个已消费或者正在消费的状态后面再想读就是空的。这也是我上面用exchange.getAttributes()做缓存的原因。把body字节数组放进exchange的attribute里后续不管是自己过滤用、还是日志记录用都能随时取到。这个套路不是XSS过滤独有的签名校验、请求体日志打印、参数加解密凡是网关上要对body做处理的场景都必须遵循“读一次、存起来、随时取”的原则。3. 过滤策略的三级设计初筛、归一化、规则匹配读出来body之后真正考验功底的是规则设计。太多人把XSS过滤简单理解为“写一串正则匹配script标签”结果上线第一天就把业务里正常包含HTML的文章给拦截了。我在这个项目里把过滤策略拆成了三步否定预筛、编码归一化、精确规则匹配再加一个字段级例外放行。3.1 XSS载荷的基本形态与编码对抗先看看常见的XSS攻击载荷长什么样。基础形态有这么几类标签类scriptalert(1)/script、iframe src//evil.com/iframe事件类img srcx onerroralert(1)、svg onloadalert(1)伪协议类a hrefjavascript:alert(1)x/a、iframe srcjavascript:...变形类大小写混合ScRiPtalert(1)/sCrIpT、引号与空格混用img/srcx/onerroralert(1)真正麻烦的是编码绕过。攻击者会把payload进行URL编码、HTML实体编码、Unicode转义甚至多重编码嵌套让简单的正则完全失效。比如script经过HTML实体编码之后变成#x3c;script#x3e;经过URL编码之后变成%3Cscript%3E经过Unicode转义变成\u003Cscript\u003E。如果网关不做任何归一化处理这些payload就会长驱直入。我的处理方案是匹配前先做一次有限度的归一化。所谓“有限度”就是对URL解码一层、把常见的HTML实体lt;、gt;、#60;、#x3c;等替换为尖括号、把\u003C这类Unicode转义还原为对应字符。不做递归解码是因为无限解码会导致两个问题一是性能损耗不可控二是会把业务里本来就正常的编码内容搞坏。归一化之后规则库只要能覆盖基础形态变形的载荷也能被拉回到可识别的形状。3.2 规则库与正则实现规则库我建议按攻击类型分组维护每一组对应一个明确的危险特征。下面是精简版的实现public class XssFilter { private static final MapString, String PATTERN_RULES new LinkedHashMap(); static { // 1. 脚本标签 PATTERN_RULES.put(scriptTag, script[^]*?.*?/script); // 2. HTML事件属性 PATTERN_RULES.put(htmlEvent, [a-zA-Z][^]*\\s(on\\w)\\s*[\]?[^]*); // 3. javascript伪协议 PATTERN_RULES.put(jsProtocol, [\\s\\\\]*javascript\\s*:); // 4. iframe/frame危险标签 PATTERN_RULES.put(dangerFrame, (iframe|frame)\\s[^]*src\\s*); // 5. 表达式注入 PATTERN_RULES.put(expression, expression\\s*\\(); } public static boolean containsXss(String input) { if (input null || input.length() 0) { return false; } // 否定预筛连作为标记的尖括号都没有直接跳过 if (input.indexOf() 0 input.indexOf() 0 !input.toLowerCase().contains(javascript)) { return false; } String normalized normalize(input); for (Map.EntryString, String entry : PATTERN_RULES.entrySet()) { Pattern p Pattern.compile(entry.getValue(), Pattern.CASE_INSENSITIVE | Pattern.DOTALL); if (p.matcher(normalized).find()) { return true; } } return false; } }这里的优化点在于否定预筛。很多body里根本没有尖括号也没有javascript字样如果把所有正则都跑一遍纯粹是浪费CPU。先用一个快速字符串查找做初筛命中概率极低的大部分请求直接就放行了等到真正需要跑正则的时候已经是确定有可疑特征的流量规则多跑几个也无妨。Pattern.compile不建议每次匹配都执行规则库初始化时预编译好放线程安全的地方复用能省不少开销。我实际对比过预编译和不预编译在高并发下的吞吐差异能到20%以上这个细节虽然不起眼但在网关这种流量密集位置还是值得注意的。3.3 字段级例外放行富文本不被误杀XSS过滤最怕的就是误杀。比如一个文章发布接口body里的content字段本来就是允许存富文本HTML的你上来一个script正则把它拦截了业务方立刻就会找你讨说法。所以我引入了字段级例外配置。做法分两步。第一步把JSON请求体用Jackson解析成JsonNode递归遍历所有字段只对文本类型的值执行规则匹配第二步配置一个xss.skipFields列表凡是命中这个列表的字段名比如content、description、summary直接跳过过滤保留原始内容。public class JsonXssScanner { private static final SetString SKIP_FIELDS Set.of(content, description, summary, remark); public static String scanAndClean(String jsonBody) { ObjectMapper mapper new ObjectMapper(); try { JsonNode root mapper.readTree(jsonBody); cleanNode(root); return mapper.writeValueAsString(root); } catch (Exception e) { // 解析失败就走字符串级过滤兜底 return XssFilter.containsXss(jsonBody) ? XssFilter.sanitize(jsonBody) : jsonBody; } } private static void cleanNode(JsonNode node) { if (node.isObject()) { node.fields().forEachRemaining(entry - { if (SKIP_FIELDS.contains(entry.getKey())) { return; } cleanNode(entry.getValue()); }); } else if (node.isArray()) { node.forEach(JsonXssScanner::cleanNode); } else if (node.isTextual()) { String value node.asText(); if (XssFilter.containsXss(value)) { ((ObjectNode) node.get()).put(, value); } } } }用Jackson解析的好处是能精确控制到字段粒度不会因为body里某一段文本碰巧包含字符就误伤整个请求。而且字段遍历顺序是结构化的跟正则全局匹配相比误杀率要低一个量级。业务上需要允许HTML的场景靠配置就能处理不需要改代码。4. 拦截响应与下游衔接过滤结果不白费过滤逻辑写完只是第一步。真正常被忽略的是后面这些事拦截之后返回给客户端什么样的响应、过滤后的body怎么转发给下游、Content-Length变化了怎么处理。这些细节不做对功能是能跑的但线上会出现各种奇奇怪怪的诡异问题。4.1 结构化拦截响应与状态码设计当请求命中XSS规则且被判定为攻击时我建议返回HTTP 400而不是403。理由是403在语义上偏向“没有权限”而XSS注入本质上是请求数据不合法属于客户端参数错误。当然如果你更习惯统一用业务状态码也可以自定一个比如40001表示“网关安全过滤拦截”。响应体统一返回JSON结构方便前端统一解析{ code: 40001, message: request blocked by security filter, traceId: 38f2a9c1e7d24bf8 }一个容易忽略的点是接口文档里要明确这个响应结构。很多前端同学遇到网关返回的拦截信息时会拿业务接口的错误结构去解析结果解析不到msg字段就报一个“系统错误”排查链路瞬间变长。我这次就把拦截响应的结构写进了统一的API错误码规范里前端对接省了很多事。再有就是响应头。拦截响应必须显式设置Content-Type: application/json;charsetUTF-8不然Gateway默认的响应头可能不是JSON格式前端拿到之后无法直接解析。还有一个安全上的细节拦截信息里不要太详细地说明是“哪条规则命中了”不然等于给攻击者做规则反馈方便他调整payload绕过。只提示“请求被安全策略拦截”就够了。4.2 Content-Length与Content-Type陷阱这一个坑我印象太深刻了。第一次上线的时候过滤后替换了body但没有处理Content-Length头结果下游服务读请求体时长度对不上解析直接失败。原因是原始请求的Content-Length是原body的长度清洗后的body变短了下游按旧的Content-Length去读字节数不匹配JSON解析就报错了。解决方案是在重建请求时要么重新计算Content-Length要么干脆把头移除。我的选择是移除旧的Content-Length让转发链路基于实际body重新计算逻辑最简单也最稳。具体实现就是在装饰器里操作headerServerHttpRequest decorator new CachedBodyServerHttpRequestDecorator( exchange.getRequest(), filteredBody.getBytes(StandardCharsets.UTF_8) ) { Override public HttpHeaders getHeaders() { HttpHeaders headers new HttpHeaders(); headers.putAll(super.getHeaders()); headers.remove(HttpHeaders.CONTENT_LENGTH); if (filteredBody ! null) { headers.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(filteredBody.getBytes(StandardCharsets.UTF_8).length)); } return headers; } };关于Content-Type也有个容易忽略的地方。如果前端传的是application/json;charsetUTF-8没问题但有些老系统会传text/plain或者干脆不规范的application/jsonbody里却是JSON。解析时要按实际字节解码不能写死UTF-8就完事。稳妥的做法是从Content-Type头里解析charset参数拿不到就默认UTF-8。4.3 用请求头把过滤结果传给业务服务既然过滤已经在前置完成下游服务最好也能感知到这个请求被“清洗”过方便做审计和追踪。我的做法是约定一个自定义请求头X-XSS-Filtered。当网关执行了清洗操作后在转发给下游的请求头上追加这个标记值取true。if (!originalBody.equals(filteredBody)) { ServerHttpRequest decorator new CachedBodyServerHttpRequestDecorator(...); exchange exchange.mutate() .request(decorator) .build(); exchange.getRequest().mutate() .header(X-XSS-Filtered, true); }这里有一个细节是exchange.mutate().request()之后如果再对exchange.getRequest()做mutate()需要注意返回值是否真的生效。我一般把header的设置放到创建装饰器之前统一在ServerHttpRequest上完成再mutate到exchange里避免二次包装导致的header丢失。下游服务拿到这个请求头之后可以做三件事一是打审计日志记录哪些请求触发了清洗二是针对清洗过的body做二次逻辑校验三是把该标记透传到更下游或者响应里方便端上排查问题。这个约定在实际排查线上数据问题时价值极大——没有它你会发现某个订单的备注内容跟用户提交的不一样却根本不知道是网关动了手脚。5. 网关层XSS过滤的三个经典兼容坑实现过程中踩过的坑比正常路径能想到的多得多。这里挑三个最容易在线上炸的兼容性问题详细说都是经过线上验证的教训。5.1 文件上传multipart的类型判断与body缓存最先出问题的是文件上传接口。multipart/form-data请求里既有文本字段又有二进制文件内容如果不加判断就把整个body读出来当字符串过滤轻则过滤毫无意义重则直接把上传文件搞坏。更气人的是某些场景下即使过滤没改内容只是多读了一遍body下游解析multipart时就报“File part not found”上传直接失败。这类请求我的处理策略是直接跳过body级过滤。二进制的文件内容不存在XSS注入语义文本字段的XSS风险可以在业务服务层或者接口参数层面单独控制。如果你实在想过滤multipart里的文本字段正确姿势是用exchange.getFormData()去读表单字段映射只过滤value不要碰文件流。但getFormData()会把文件上传也缓存到内存里有OOM风险所以还是建议直接放行把注意力集中在纯JSON和表单请求上。5.2 大请求体过滤延迟与吞吐权衡上线之前压测我特意测了大body场景。为了模拟真实情况构造了50KB、500KB、1MB、3MB四档请求体分别跑无过滤和带过滤的链路。结论跟预期一致body越大过滤耗时占比越高。1MB的JSON请求无过滤时网关转发耗时约12ms开启完整过滤后涨到约20ms多出来的8ms主要集中在JSON解析遍历和字符串正则匹配上3MB请求体时差距就更明显了。如果你觉得“多8ms又怎样”千万别忽视QPS的放大效应。网关入口QPS假设2000平均每个请求多8ms意味着并发窗口里要多塞16个请求在排队延迟的尾部指标会明显恶化。所以大请求体必须做降级处理超过2MB的body直接跳过字段级过滤只做字符串级快速预筛预筛都没命中危险特征就直接放行不碰JSON解析。这样能把大请求的额外延迟控制在1ms以内。5.3 Spring Security过滤器链与Order设置很多网关项目里同时存在Spring Security的WebFilter链和Gateway的GlobalFilter链。两条链的执行顺序如果不理顺会出现“Security的认证过滤器已经把body读走了我的XSS过滤器再读就空了”的诡异情况。GlobalFilter用getOrder()控制顺序值越小越先执行。我设置为-1000目的就是让XSS过滤尽量排在前面。但注意不是说Order越小越好——如果你比CsrfFilter还靠前而CsrfFilter又需要读body做校验同样会造成body被提前消费的问题。最稳的设计是XSS过滤作为第一个读取body的环节读取后把body缓存进exchange attributes后续无论Security还是其他filter要用body都从缓存读取而不是重新订阅底层流。我还遇到过一个更隐蔽的问题某些请求经过网关转发后缓存装饰器没有传递到后续的NettyRoutingFilter导致下游服务收到的还是空body。排查下来发现是mutate().request()的装饰器没有正确注册到exchange的全局缓存里。解决方式是参照ServerWebExchangeUtils.cacheRequestBody()的实现在缓存body的同时设置CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR这样路由过滤器会优先使用缓存装饰器获取body。6. 上线前压测与灰度建议安全模块上线最怕的不是功能有问题而是上线后影响所有业务流量。所以我强烈建议XSS过滤一定要走“观察模式 → 精准控制 → 灰度放量”三步而不是一把梭直接全量拦截。6.1 过滤器的性能损耗参考以我上面的实现为例压测机上4核8G网关单实例纯JSON请求、body大小在10KB左右时无过滤链路P99大约12ms增加完整XSS过滤后P99大约16ms损耗4ms左右。如果开启否定预筛和大小阈值降级实际生产环境混入大请求后的P99恶化幅度基本能控制在5%以内。4ms的延迟在大多数业务场景下是可接受的但有两个前提一是规则必须预编译且数量克制二是初筛剪枝逻辑必须生效。如果你的网关机器核数少或者流量大建议单独给过滤器链之间预留线程资源避免正则匹配阻塞EventLoop线程影响其他请求的处理。6.2 观察模式、灰度放量与监控指标观察模式是整个灰度过程的地基。过滤器里设置一个开关xss.filter.modeobserve命中规则的请求不拦截、不清洗只打印一条结构化日志包含请求ID、命中规则、命中的字段内容前100个字符。跑上几天把日志收集起来分析你就会直观地看到哪些接口经常命中、哪些payload其实是业务正常内容、哪些规则误杀率偏高。根据观察数据调整规则和例外放行列表等到误杀率降到足够低再把模式切到enforce。灰度放量按服务维度做而不是按节点百分比做。因为不同业务的body结构差异很大比如文章服务富文本多、订单服务参数短平快混在一起看不出真实情况。我在项目里是用配置中心按路由维度控制先给低频的管理后台服务开启拦截再逐步扩大到用户侧核心服务每个阶段观察误杀反馈和延迟变化稳定后再全部覆盖。监控指标建议至少包含四项过滤命中量按规则维度、实际拦截量、清洗量、下游感知的误杀反馈量。前三项从网关日志里直接统计误杀反馈量就得靠业务方配合定期收集“用户提交被异常拦截”的工单。有了这四类数据你才能持续调优规则让过滤模块越跑越准。最后分享一个我的个人习惯每次调整XSS过滤规则我都会在测试环境先跑一遍固定的攻击样本集从经典script到各种编码绕过形式确保原来能拦的payload没有被新改动放漏。这个习惯救过我太多次了。安全过滤这个东西规则不是越多越好而是在有限性能预算内把攻击面收得足够窄同时不打扰正常用户。网关层的XSS过滤说到底就是给微服务架构加一道前置安检门设计得收敛、可控它就能安安静静地守在那里。