聊到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,请求体是Flux<DataBuffer>,不是InputStream。DataBuffer是Netty里面的字节容器,请求体会被切成一块一块的流式数据。你直接调用getBody()拿到的只是一个响应式流,需要自己去订阅、收集、拼接,才能还原出完整的body字节数组。
这个模型跟传统Servlet的差异,我用一个生活化的类比来解释:Tomcat里的请求体像是服务员直接端上来的一盘菜,你拿筷子想怎么拨就怎么拨;WebFlux的请求体则像是中央厨房出来的一条流水线,菜是一块一块传过来的,你如果想检查整桌菜,得先让流水线停下来,把所有块拼接齐再动手。网关过滤要做的事,就是把流水线上的每一块都接住,拼成完整内容,检查完再重新放回流水线上。
而且更关键的是,Flux<DataBuffer>是单次订阅的。你过滤时读了一遍,后面业务转发时如果再去读,拿到的是空流。这也是网上很多“网关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 Mono<Void> 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)对应的DataBuffer:
private static class CachedBodyServerHttpRequestDecorator extends ServerHttpRequestDecorator { private final byte[] cachedBody; public CachedBodyServerHttpRequestDecorator(ServerHttpRequest delegate, byte[] cachedBody) { super(delegate); this.cachedBody = cachedBody; } @Override public Flux<DataBuffer> 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攻击载荷长什么样。基础形态有这么几类:
- 标签类:
<script>alert(1)</script>、<iframe src=//evil.com></iframe> - 事件类:
<img src=x onerror=alert(1)>、<svg onload=alert(1)> - 伪协议类:
<a href="javascript:alert(1)">x</a>、<iframe src="javascript:..."> - 变形类:大小写混合
<ScRiPt>alert(1)</sCrIpT>、引号与空格混用<img/src=x/onerror=alert(1)>
真正麻烦的是编码绕过。攻击者会把payload进行URL编码、HTML实体编码、Unicode转义,甚至多重编码嵌套,让简单的正则完全失效。比如<script>经过HTML实体编码之后变成<script>,经过URL编码之后变成%3Cscript%3E,经过Unicode转义变成\u003Cscript\u003E。如果网关不做任何归一化处理,这些payload就会长驱直入。
我的处理方案是:匹配前先做一次有限度的归一化。所谓“有限度”,就是对URL解码一层、把常见的HTML实体(<、>、<、<等)替换为尖括号、把\u003C这类Unicode转义还原为对应字符。不做递归解码,是因为无限解码会导致两个问题:一是性能损耗不可控,二是会把业务里本来就正常的编码内容搞坏。归一化之后,规则库只要能覆盖基础形态,变形的载荷也能被拉回到可识别的形状。
3.2 规则库与正则实现
规则库我建议按攻击类型分组维护,每一组对应一个明确的危险特征。下面是精简版的实现:
public class XssFilter { private static final Map<String, 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.Entry<String, 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 Set<String> 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;charset=UTF-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重新计算,逻辑最简单也最稳。具体实现就是在装饰器里操作header:
ServerHttpRequest 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;charset=UTF-8,没问题;但有些老系统会传text/plain或者干脆不规范的application/json,body里却是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.mode=observe,命中规则的请求不拦截、不清洗,只打印一条结构化日志,包含请求ID、命中规则、命中的字段内容前100个字符。跑上几天,把日志收集起来分析,你就会直观地看到:哪些接口经常命中、哪些payload其实是业务正常内容、哪些规则误杀率偏高。根据观察数据调整规则和例外放行列表,等到误杀率降到足够低,再把模式切到enforce。
灰度放量按服务维度做,而不是按节点百分比做。因为不同业务的body结构差异很大,比如文章服务富文本多、订单服务参数短平快,混在一起看不出真实情况。我在项目里是用配置中心按路由维度控制:先给低频的管理后台服务开启拦截,再逐步扩大到用户侧核心服务,每个阶段观察误杀反馈和延迟变化,稳定后再全部覆盖。
监控指标建议至少包含四项:过滤命中量(按规则维度)、实际拦截量、清洗量、下游感知的误杀反馈量。前三项从网关日志里直接统计,误杀反馈量就得靠业务方配合,定期收集“用户提交被异常拦截”的工单。有了这四类数据,你才能持续调优规则,让过滤模块越跑越准。
最后分享一个我的个人习惯:每次调整XSS过滤规则,我都会在测试环境先跑一遍固定的攻击样本集,从经典<script>到各种编码绕过形式,确保原来能拦的payload没有被新改动放漏。这个习惯救过我太多次了。安全过滤这个东西,规则不是越多越好,而是在有限性能预算内,把攻击面收得足够窄,同时不打扰正常用户。网关层的XSS过滤,说到底就是给微服务架构加一道前置安检门,设计得收敛、可控,它就能安安静静地守在那里。