做网关开发的朋友应该都遇到过这种场景上线前的安全扫描一轮跑完群里面截图里全是XSS告警开发组连夜在业务代码里补转义、换输出编码改动量大又容易漏。这时项目组容易冒出一个诉求“能不能在网关层统一拦一道把XSS过滤做成全站通用能力”于是就有了Spring Cloud Gateway网关XSS过滤这个需求。这个方案的核心思路很简单所有经过网关的请求在转发到下游业务服务之前先对请求参数和请求体做一次安全清洗把常见的XSS攻击载荷去掉或者无害化。适合的场景也很明确——多服务架构下你没有精力逐个服务补安全逻辑或者历史老系统没法快速改造时网关统一过滤是性价比很高的兜底手段。这篇文章把我自己落地这个过滤器的完整思路、踩坑过程、关键代码和排障经验都整理出来希望能帮你少走弯路。1. 为什么要把XSS过滤做在Spring Cloud Gateway网关上1.1 网关层统一过滤的核心价值在微服务架构里如果每个服务各自维护一套XSS过滤逻辑基本就是一场灾难。A服务用正则替换B服务用第三方库清洗C服务干脆没做。等到安全扫描报告出来你只能在各个服务之间来回打补丁维护成本极高。把XSS过滤收口到网关业务服务眼里只有“干净”的请求内部的参数处理和数据库写入逻辑可以保持相对纯粹。网关上做过滤还有一个隐性收益过滤逻辑只部署一次所有下游服务共享。新增一个服务时不需要关心它有没有接XSS防护网关天然已经替它挡了一道。对于老系统改造或者快速上线场景这是非常大的便利。另外网关过滤非常适合处理“跨多个服务的公共攻击面”。比如一个用户参数字段用户服务存订单服务查后台服务展示攻击载荷只要打中一次可能影响三个服务。集中在网关处理后攻击面被有效收敛就不用每一条链路上的服务都去加固。1.2 Spring Cloud Gateway和传统Servlet过滤器有本质区别很多同学第一次实现时习惯性地去网上搜“过滤器怎么拦截”搜出来的多半是Servlet的Filter、HttpServletRequestWrapper什么getParameter、getInputStream、setCharacterEncoding全套用上。但Spring Cloud Gateway基于Spring WebFlux和Reactor Netty它是响应式非阻塞的底层压根没有Servlet API自然也没有HttpServletRequest。这一点是最大的坑。我见过有人硬把一个Servlet过滤器塞到Gateway里结果请求根本进不来或者body读了一次后下游就再也拿不到参数了。因为WebFlux的请求体是一个FluxDataBuffer它是响应式流数据是一次性的读走之后流就空了。你必须通过装饰器模式去重写请求体才能做到“读取-清洗-重新构造”这个流程。理解这一点后方案就清晰了用GlobalFilter加ServerHttpRequestDecorator替换请求的body流和query参数然后再把新的ServerWebExchange放行给过滤链。2. XSS过滤到底要过滤什么攻击模型与清洗策略2.1 XSS攻击的本质与常见注入点XSS全称跨站脚本攻击本质是攻击者把恶意脚本注入到页面中让它在其他用户的浏览器里执行。最常见的两类是反射型和存储型。反射型是通过请求参数直接把脚本带进页面存储型则是把恶意内容写进数据库等后续某个页面渲染时爆发。落到网关过滤的范畴我们可以从三个维度去识别攻击载荷攻击类型典型负载示例攻击意图标签注入scriptalert(1)/script直接执行脚本事件属性注入img srcx onerroralert(1)在图片加载失败时触发脚本伪协议注入a hrefjavascript:alert(1)点击/a点击链接时执行脚本外部加载iframe src恶意地址/iframe加载第三方攻击页面编码绕过%3Cscript%3E、#x3c;script#x3e;通过编码逃避关键字检查需要注意网关层处理的大多是反斜杠编码、URL编码和HTML实体编码。如果攻击载荷在到达网关时已经被框架解码过一次那么你拿到的是解码后的明文相对好识别如果没有解码则要关注%3C这类编码变体。2.2 过滤、转义和校验别把业务数据洗坏实现XSS过滤时很容易走进一个误区试图把所有字符串里的和全都替换成lt;和gt;。这种做法确实安全但会破坏业务数据。比如一个正常的富文本内容b加粗/b经过这种清洗后前端渲染出来的就是纯文本而不是加粗效果了。所以网关层过滤要分清场景需要严格删除的场景纯用户输入、评论、昵称、地址等不作为富文本渲染的字段出现script、javascript:、onerror这类高危特征时直接删除或替换。需要保留结构但无害化的场景业务确实要存HTML片段时网关层不应该做粗暴替换而是把事件属性和危险协议过滤掉保留普通标签。应该交给语义层校验的场景涉及URL、业务数字、枚举值等不是XSS过滤能解决的需要业务侧做参数校验。我的建议是网关层过滤定位为“兜底策略”核心目标是消除高危脚本执行的特征而不是彻底替代应用层安全框架。宁可过滤稍保守也不能把正常业务数据洗坏否则上线之后全是功能故障工单。3. 基于GlobalFilter的XSS过滤实操3.1 技术选型与过滤器注册方式Spring Cloud Gateway里的过滤器主要分三种GatewayFilter、GlobalFilter和普通的WebFilter。GatewayFilter一般绑定到某个路由上局限性太明显不适合全局限流WebFilter层级较低适合做一些通用的跨切面逻辑但不太好拿到Gateway路由上下文。最适合做全站XSS过滤的就是GlobalFilter配合Component注册默认对所有路由生效。同一条链路上可能会有多个GlobalFilter比如鉴权过滤器、日志过滤器、灰度过滤器。通过实现Ordered接口返回order值可以控制执行顺序。XSS过滤建议放在鉴权之后、路由转发之前这样既能提前拦截非法内容又不会在未认证请求上浪费资源。我在项目里把order设为-100比大多数自定义业务过滤器优先级略高确保在业务处理前完成清洗。如果你们有网关级限流或分布式追踪过滤器建议把XSS过滤放到它们之后避免日志里记录的是被清洗后的内容失去排查攻击线索的价值。3.2 请求体读取与重写WebFlux响应式核心逻辑要实现body清洗核心在于重写ServerHttpRequest的getBody()。正常情况下请求体会被下游的服务或全局过滤器消费一次消费完就没了。为了能在清洗后继续传递给链路我们需要继承ServerHttpRequestDecorator在getBody()里先消费原始的body流清洗后重新创建一个新的FluxDataBuffer返回给下游。话不多说直接看核心代码import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; Component public class XssRequestGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); // GET无body直接放行其他的再判断 if (HttpMethod.GET.equals(request.getMethod())) { return chain.filter(exchange); } // 只处理JSON和表单类型的请求体 String contentType request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); if (contentType null || (!contentType.contains(MediaType.APPLICATION_JSON_VALUE) !contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE))) { return chain.filter(exchange); } ServerHttpRequest sanitizedRequest new ServerHttpRequestDecorator(request) { Override public HttpHeaders getHeaders() { HttpHeaders headers new HttpHeaders(); headers.putAll(super.getHeaders()); // body内容变化后原始Content-Length可能不适用移除掉好让框架重新计算 headers.remove(HttpHeaders.CONTENT_LENGTH); headers.remove(HttpHeaders.TRANSFER_ENCODING); return HttpHeaders.readOnlyHttpHeaders(headers); } Override public FluxDataBuffer getBody() { // 通过DataBufferUtils.join把请求体全部累积为一个DataBuffer return DataBufferUtils.join(super.getBody()) .map(dataBuffer - { byte[] originalBytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(originalBytes); DataBufferUtils.release(dataBuffer); String originalBody new String(originalBytes, StandardCharsets.UTF_8); String cleanBody XssSanitizer.sanitize(originalBody); byte[] cleanBytes cleanBody.getBytes(StandardCharsets.UTF_8); return exchange.getResponse().bufferFactory().wrap(cleanBytes); }) .flux(); } }; ServerWebExchange sanitizedExchange exchange.mutate() .request(sanitizedRequest) .build(); return chain.filter(sanitizedExchange); } Override public int getOrder() { return -100; } }这里有几个细节值得注意。DataBufferUtils.join(super.getBody())会把整个请求体聚合成一个MonoDataBuffer这样做的好处是能在内存里拿到完整的body字符串方便整体清洗。缺点是要等整个body到达后才处理对于超大上传文件不友好。所以我在前面限制了只处理JSON和URL编码的表单文件上传的multipart请求不会走到这里避免把大文件加载进内存。exchange.getResponse().bufferFactory().wrap(cleanBytes)这一步很多人会写错。如果直接使用new DefaultDataBufferFactory().wrap(...)在某些并发场景下可能有问题。最好通过exchange.getResponse().bufferFactory()获取当前环境上下文的缓冲区工厂它知道如何处理内存池和非池化缓冲区。还有一处是getHeaders()里移除Content-Length。我刚开始没做这一步清洗后body长度和原始长度不一致下游服务解析时发现长度对不上直接报异常。移除后WebFlux会按实际body流重新计算长度问题就解决了。如果你的请求体是chunked编码移除Transfer-Encoding同样重要。3.3 清洗规则引擎的落地实现清洗逻辑我单独抽成了一个XssSanitizer工具类重点维护一套黑名单规则和一个JSON字段级清洗方法。直接对原body做正则替换在某些情况下会误伤JSON结构所以我优先考虑将JSON解析成树节点遍历字符串值清洗后重写。如果body不是合法JSON再降级为全局正则替换。先看工具类实现import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; public class XssSanitizer { private static final ObjectMapper OBJECT_MAPPER new ObjectMapper(); // 删除完整的script标签 private static final Pattern SCRIPT_TAG_PATTERN Pattern.compile((?is)script[^]*.*?/script); // 删除危险标签iframe、object、embed private static final Pattern DANGEROUS_TAG_PATTERN Pattern.compile((?is)/?(iframe|object|embed)[^]*); // 匹配事件属性例如 onerror onload onclick private static final Pattern EVENT_ATTR_PATTERN Pattern.compile((?is)\\son(\\w)\\s*); // 匹配 javascript: 伪协议 private static final Pattern JAVASCRIPT_PSEUDO_PATTERN Pattern.compile((?is)javascript:); public static String sanitize(String input) { if (input null || input.isEmpty()) { return input; } // 尝试JSON结构化清洗 String result sanitizeJson(input); if (result ! null) { return result; } // 非JSON场景正则清洗 return sanitizeWithRegex(input); } private static String sanitizeJson(String json) { try { JsonNode root OBJECT_MAPPER.readTree(json); if (root null) { return json; } sanitizeNode(root); return OBJECT_MAPPER.writeValueAsString(root); } catch (Exception e) { return null; } } private static void sanitizeNode(JsonNode node) { if (node instanceof ObjectNode) { ObjectNode obj (ObjectNode) node; obj.fields().forEachRemaining(entry - { JsonNode value entry.getValue(); if (value instanceof TextNode) { String safeValue sanitizeWithRegex(value.asText()); obj.put(entry.getKey(), safeValue); } else if (value instanceof ObjectNode || value instanceof ArrayNode) { sanitizeNode(value); } }); } else if (node instanceof ArrayNode) { ArrayNode array (ArrayNode) node; ListJsonNode nodes new ArrayList(); array.forEach(nodes::add); for (int i 0; i nodes.size(); i) { JsonNode value nodes.get(i); if (value instanceof TextNode) { array.set(i, TextNode.valueOf(sanitizeWithRegex(value.asText()))); } else if (value instanceof ObjectNode || value instanceof ArrayNode) { sanitizeNode(value); } } } } private static String sanitizeWithRegex(String value) { if (value null) { return null; } // 删除完整script标签包括内部内容 value SCRIPT_TAG_PATTERN.matcher(value).replaceAll(); // 删除危险标签 value DANGEROUS_TAG_PATTERN.matcher(value).replaceAll(); // 将事件属性名中的on去掉变成普通属性这里采用去掉on前缀的写法 // 更安全的做法是直接去掉整个属性声明将 onerror替换为 disabled value EVENT_ATTR_PATTERN.matcher(value).replaceAll( x-$1); // javascript: 伪协议替换为空 value JAVASCRIPT_PSEUDO_PATTERN.matcher(value).replaceAll(); return value; } }这个清洗规则的思路是“去危险特征保基本格式”。对于onerror我把它替换成x-error这样既不会破坏原有属性名结构又让浏览器不再识别为事件处理器。要注意replaceAll( x-$1)这步之后事件属性仍然存在但前缀变了如果业务代码里有RequestParam获取原始属性会拿到修改后的值这点在业务侧要提前沟通。如果你希望完全删除事件属性而不是改名也可以改成EVENT_ATTR_PATTERN.matcher(value).replaceAll( )把onerror连同值一起替换为空格。这个看团队策略我建议保留一个可配置的开关不同路由可以指定不同的清洗强度。3.4 对URL Query参数和表单参数的过滤Body清洗只是其中一半反射型XSS还有一大半通过URL Query参数传输比如/search?keywordscript...。这类参数在Spring Cloud Gateway里可以通过重写getQueryParams()来清洗。实现方式类似import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; // 在ServerHttpRequestDecorator中重写getQueryParams Override public MultiValueMapString, String getQueryParams() { MultiValueMapString, String originalParams super.getQueryParams(); MultiValueMapString, String cleanedParams new LinkedMultiValueMap(); originalParams.forEach((key, values) - { ListString cleanedValues new ArrayList(); for (String value : values) { cleanedValues.add(XssSanitizer.sanitize(value)); } cleanedParams.put(key, cleanedValues); }); return cleanedParams; }不过这里有一个隐患如果下游业务通过ServerHttpRequest.getURI()获取原始URI再通过getRawQuery()解析参数那么即使你重写了getQueryParams()它拿到的仍是原始未清洗的query string。某些老式的API网关会这么做所以你会看到“参数明明过滤了但日志里还能看到原始恶意串”的情况。更彻底的做法是同时重写getURI()构建一个清洗后的URI。Override public URI getURI() { URI originalUri super.getURI(); String query originalUri.getRawQuery(); if (query null || query.isEmpty()) { return originalUri; } try { // 对query按和拆分清洗value后重新拼接 String[] pairs query.split(); StringBuilder safeQuery new StringBuilder(); for (String pair : pairs) { String[] kv pair.split(, 2); if (kv.length 2) { String key URLDecoder.decode(kv[0], StandardCharsets.UTF_8.name()); String value URLDecoder.decode(kv[1], StandardCharsets.UTF_8.name()); String safeValue XssSanitizer.sanitize(value); safeQuery.append(kv[0]).append() .append(URLEncoder.encode(safeValue, StandardCharsets.UTF_8.name())) .append(); } } if (safeQuery.length() 0) { safeQuery.deleteCharAt(safeQuery.length() - 1); } return new URI(originalUri.getScheme(), originalUri.getAuthority(), originalUri.getPath(), safeQuery.toString(), originalUri.getFragment()); } catch (Exception e) { return originalUri; } }但这里有个矛盾如果同时重写了getQueryParams()和getURI()两者都基于原始数据清洗如果不小心把query中某个参数值改了会让参数解析后的值和URI中的值不一致下游拿到的参数和重定向场景可能错乱。我更推荐的做法是统一以getQueryParams()为准如果遇到必须依赖getURI()的下游再专门重写getURI()并保证两边清洗逻辑完全一致。生产环境要评估好业务依赖不要盲目双重过滤。对于application/x-www-form-urlencoded类型的请求它的body本来也是一串keyvaluekey2value2和query的处理方式类似。可以复用上面的getQueryParams逻辑不过需要先读body再按格式解析。一般更简单的做法是直接对整段body做字符串替换因为表单字段里出现script等特征的频率较高而且表单的层次结构比JSON简单正则清洗不容易误伤。4. 实测验证、常见问题与线上排障4.1 用Post构造XSS攻击用例进行验证过滤逻辑写完后一定要用真实请求做一轮攻击用例回归。我的验证清单是这样的场景请求内容预期结果JSON body带script{name:scriptalert(1)/script}下游收到name为空或alert(1)表单字段带事件usernameimg srcx onerroralert(1)下游收到x-erroralert(1)Query参数携带伪协议?urljavascript:alert(1)下游收到urlalert(1)富文本普通标签{content:b加粗/b}标签保留不被误删文件上传multipartfile二进制数据不进入过滤流程原样转发非JSON体rawplain text不进入过滤流程用Postman或Apifox都可以重点观察下游接收到的实际内容同时看网关日志有没有内存压力异常。对于multipart/form-data类型我在前面已经通过Content-Type判断直接放行避免大文件被加载到内存。如果你的业务必须对multipart中的文本表单字段也做清洗那要单独处理比如解析FluxDataBuffer为多个Part这种场景我建议还是放到业务服务处理更稳妥。4.2 常见问题速查表我把实际使用中踩过的坑和同事们反馈较多的问题整理成了速查表问题现象可能原因解决方案下游服务报“请求体为空”在GlobalFilter之前已经有其他过滤器消费了body将本过滤器order提前或者复用缓存请求体的全局过滤器Content-Length不匹配导致解析失败body清洗后长度变化但没有更新头部在装饰器的getHeaders()中移除Content-Length和Transfer-Encoding中文乱码使用了平台默认字符集解码字节显式使用StandardCharsets.UTF_8JSON数字被转成字符串用字符串替换方式清洗JSON导致类型变化使用Jackson树节点做字段级清洗只操作TextNode性能下降明显每个非GET请求都做body整体聚合和JSON解析限制Content-Type对超大请求体直接放行加白名单路由query参数清洗后仍触发XSS下游使用getURI().getRawQuery()补上getURI()重写保持getQueryParams()与URI一致越权或业务数据被篡改的恐慌清洗规则过强误删正常字符维护一份字段白名单对特定字段跳过清洗并记录审计日志这里单独说下“请求体为空”这个问题它非常隐蔽。Gateway链路里如果某个过滤器先调用了exchange.getRequest().getBody()做了日志记录或者参数校验那么到你自己的过滤器时body流已经空了。此时DataBufferUtils.join(super.getBody())拿到的是一个空流下游自然收不到任何body。解决方式通常有两种一是让自己这个过滤器的order尽量小优先级更高保证先于其他body消费类过滤器执行二是引入请求体缓存机制在最早的两个内置过滤器处把原始body缓存到exchange属性里后续所有过滤器都可以反复读取。后一种方案更通用但实现复杂度会高一些。4.3 性能与内存开销的真实体验我在测试环境用大约100QPS的请求做了压测每个请求体平均约5KB过滤器的额外延迟在1-3ms左右内存占用没有明显上涨。但当请求体达到500KB以上时如果还用DataBufferUtils.join全量聚合内存峰值会比较高。所以在生产环境我加了阈值判断请求头的Content-Length超过1MB时直接放行不做body清洗。query参数一般很小所以可以放心清洗。另外JSON的readTree和writeValueAsString会引入序列化开销在压测中大概占过滤总耗时的60%。如果对性能要求极为苛刻可以把清洗规则换成基于原始字符串的局部正则替换虽然可能误伤结构但速度确实快不少。我的建议是性能和准确率之间做权衡先用结构清洗跑一轮压测出了问题再看瓶颈在哪里。5. 这套方案的边界与后续优化方向5.1 并不是所有场景都适合在网关层过滤先泼一盆冷水网关上做XSS过滤能挡住“意外流进来的恶意请求”但它始终存在三个先天短板。第一网关看不到页面渲染上下文。XSS是否真的造成危害取决于这个值最终在什么位置输出。同样是script输出在HTML标签内和JavaScript字符串里攻击效果完全不同。网关只会做统一的“特征清洗”没法做到上下文感知的精细处理。第二清洗会改变参数内容。如果业务方对某些字符串有强校验比如用户昵称允许包含和作为装饰字符网关清洗后可能直接导致业务校验失败。这种情况不能一刀切需要通过路由白名单或字段白名单跳过。第三存储型XSS的二次传播防不住。网关只能清洗“这次请求进入系统”的数据如果恶意脚本已经在数据库里待了很久某个页面读取了它进行渲染网关根本不在链路里。这个短板需要应用层输出编码来补。所以我对团队的定位是网关过滤是“第一道防线”不是“唯一防线”。它负责把大多数无脑扫描脚本挡在外面降低安全事件发生概率真正的纵深防护还是要配合应用层入参校验、输出编码、WAF等多层手段。5.2 可以做的后续优化扩展如果你已经跑通了上面这套基础过滤器接下来可以考虑下面这几个方向配置化白名单通过配置中心动态维护“不参与清洗”的路由、参数名或字段路径遇到业务特殊字段时能快速放行不用改代码发版。攻击日志审计在清洗动作发生时把原始请求URL、原始body、清洗后body、客户端IP等记录到专门的审计日志方便安全团队溯源。限流联动对同一IP在短时间内频繁触发高风险XSS特征的情况可以联动网关限流把扫描器直接封掉。响应体注入检测反向方向是对响应也做扫描检测是否存在存储型XSS导致的页面解析异常不过响应体处理比请求体复杂得多需要谨慎开启。我在实际维护这套过滤器时体会最深的一点是不要追求“零误杀”或“零漏杀”而是在工程可维护性和安全覆盖之间找一个平衡点。规则太强业务天天投诉规则太弱安全扫描又天天蹦告警。最好的做法是把规则拆成“高危必杀”和“低危可配置”两层线上先跑高危层稳定后再逐步放开低危层。XSS过滤这种“安全横切面”放到网关层确实省心但它不是银弹。团队的沟通也很关键过滤器上线前要拉上所有业务负责人过一遍规则告诉他们哪些字符会被清洗哪些字段需要申请白名单。如果上线前不沟通上线后你会在故障群里看到各种“我传了个哈哈哈你怎么给我改没了”的抱怨。提前把规则讲清楚后面能省一大堆解释成本。最后分享一个小技巧在用Postman调试时先把Content-Length取消勾选让Postman自动生成避免本地工具和网关的header处理相互干扰。这个细节虽然不起眼但确实帮我省了不少排查时间。