ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

存储型XSS后端防御:基于注解与Filter的全量清洗方案

存储型XSS后端防御:基于注解与Filter的全量清洗方案

先说一个我前阵子遇到的真实案例。我们某个管理后台的“个人备注”功能被人塞了一行<script>alert(document.cookie)</script>,当时没有造成实际损失,但这段代码在整个查询页面回显,同事一打开列表,浏览器直接弹窗。查到最后,问题不在前端,不在网关,而是后端接口把用户输入原封不动地写进了数据库。

存储型 XSS 难防就难在“写入时无人感知,读取时全面爆发”。只要有人绕过前端直接打接口,或者前端校验逻辑被改,脏数据就能成功入库。后面我在团队里落地了一套纯后端轻量方案:一个注解@XssClean标记需要清洗的接口,一个XssFilter类统一切入,针对前台传入的普通参数、请求头、JSON Body 做清洗和拦截,不依赖任何第三方安全框架,整套代码量可控,适合大多数中小团队直接参考。

1. 为什么存储型 XSS 必须靠后端兜底

1.1 攻击路径与危害面

存储型 XSS 的攻击流程可以概括为三步:攻击者把恶意脚本作为正常内容提交给服务端,服务端将其持久化到数据库;其他用户请求某个页面时,服务端把这段内容拼进 HTML 响应返回;浏览器解析并执行了脚本,攻击者达成目的。

和反射型 XSS 相比,存储型的危害面更大。反射型是一次性的,攻击者必须诱导受害者点击特定链接;存储型只要脚本成功入库,后续所有访问对应页面的人都会中招。它直接威胁到 cookie、会话令牌、页面内容完整性,甚至可以配合钓鱼页面做二次攻击。常见出现场景包括昵称、签名、留言、评论、问卷调查结果、个人简介等需要用户填写文本的地方。

我在项目里排查时还发现一个现实问题:恶意数据一旦写入数据库,光靠改代码是救不了存量数据的,历史脏数据还躺在库里,需要写脚本清洗或者人工处理。这也是我后来坚持在入口做统一拦截的原因——防住源头,比事后擦屁股便宜得多。

1.2 前端校验为什么拦不住

前端加校验,比如限制输入长度、过滤特殊字符、正则匹配,本质上只是对人机交互的一种约束,不是协议层的约束。我平时调试接口经常直接撸一段 curl 报文往服务端打,页面里的v-model规则、onblur校验、正则表达式一个都不会执行。

换句话说,只要后端对外暴露了 HTTP 接口,攻击者随时可以构造原始报文绕过前端。接口联调阶段,团队内部为了传参方便也常常跳过前端页面直接请求后端。如果后端接口默认信任“前端已经过滤过”,那就等于把所有入口都敞开给攻击者。

安全领域有一句话值得反复强调:一切用户输入都不可信。这个“输入”不仅指表单字段,还包括请求头、Cookie、上传文件名,甚至路径参数。纯后端拦截的意义就在这里,它不看前端脸色,在数据进入业务逻辑之前就把危险内容处理掉。

1.3 从成本看方案取舍

当时团队里有人提出引入现成的安全过滤器,也有人建议直接用 Owasp 的 HTML Sanitizer 或 Jsoup 做白名单清洗。这些方案功能强大,但对一个以普通接口为主、没有富文本编辑需求的项目来说,引入成本和维护成本都不低。

我更倾向于做一个“最小可用”的组件:不改造现有接口,不打扰没有标注的请求,用一个注解加上一个类就能说清楚逻辑。新同学接手时,看一眼注解就知道哪些接口做了防护,不用去翻几百行配置。这个思路也是这篇文章的核心,先讲清楚为什么这么设计,再给完整代码。

2. 方案推演:Filter、拦截器、AOP 到底选哪个

2.1 Spring Boot 请求链路里它们分别在哪一步

Spring Boot 处理一个 HTTP 请求的完整链路大致是:Servlet Filter 最先执行,然后进入 DispatcherServlet,再经过 HandlerInterceptor,最后通过 HandlerAdapter 调用 Controller 方法。AOP 拦截则发生在目标方法调用前后,是最接近业务代码的一层。

如果要在请求进入 Controller 之前统一清洗入参,最简单的想法当然是越早越好。Filter 处于链路最前端,此时 request 还没有被解析成 Controller 的入参;Interceptor 距离 Controller 更近,但参数绑定流程还没开始;AOP 则已经拿到方法签名和参数值了。

这个链路顺序直接决定了工具选型。用 Filter 可以在参数绑定之前把 request 对象替换成包装器,让后续所有解析逻辑都读到清洗后的数据;用 Interceptor 则只能做“检查”,做不了“替换”;用 AOP 则需要在同一套切面里处理不同接口的复杂参数结构。

2.2 HandlerInterceptor 的坑:包装不了 request

我第一次做这个需求时,很自然地想用 HandlerInterceptor。理由也很直接:拦截器能拿到 HandlerMethod,可以直接读取方法上的注解,语义非常清晰。但实现到一半就踩了坑。

preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)里虽然可以拿到 request 对象,但这个对象是 DispatcherServlet 传入的。你在方法里写request = new XssWrapper(request),只是把局部变量重新赋值了,DispatcherServlet 后续做参数解析时,用的仍然是原始 request。Spring MVC 的数据绑定发生在 HandlerAdapter 阶段,它不会回头去找拦截器里生成的新对象。

这一点非常关键,也是很多文章没点破的坑。拦截器适合做登录校验、权限校验、请求日志这类“看一眼就放行”的事情,不适合做“把请求对象换掉再传给下游”的事情。真要替换 request,必须借助 Servlet 层面的 Filter。

2.3 最终方案:注解驱动的过滤器

我的方案是结合两者优点:使用OncePerRequestFilter做真正的清洗,在doFilterInternal里把 request 包装后通过filterChain.doFilter(wrapper, response)传下去,后续整个链路拿到的都是清洗后的参数来源。

注解的读取则靠注入RequestMappingHandlerMapping,在过滤器中动态查询当前请求对应的 HandlerMethod。如果方法或类上标注了@XssClean,就进入清洗流程;没有标注的接口完全不受影响。这个组合拿到了注解的语义控制能力,又保留了 Filter 早期介入的能力。

回调一下文章标题说的“一个类加一个注解结束”,实际交付物就是这两个东西:一个负责声明哪些接口需要清洗的注解,一个负责清洗的过滤器类。注解不只是开关,还能指定清洗模式和排除字段,后面我会详细说明。

3. 核心代码:一个注解加一个类实现全量清洗

3.1 注解定义与参数设计

import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface XssClean { boolean enabled() default true; XssType type() default XssType.STRICT; String[] exclude() default {}; enum XssType { STRICT, LOOSE } }

三个字段的设计理由如下。

enabled是为了让你在类上统一开开关。比如一个 Controller 类整体标注了@XssClean,其中某个接口不需要清洗,可以在方法上写@XssClean(enabled = false)局部关掉。

type区分严格模式和宽松模式。严格模式对输入做 HTML 实体转义,把<script>变成&lt;script&gt;,适合昵称、备注等纯文本字段;宽松模式只剥离 script、iframe 标签、事件属性和危险协议,保留基础格式,适合需要保留简单排版的内容。

exclude用于指定不处理哪些字段。比如富文本字段希望原样保留给编辑器,可以在注解里把字段名列出来,比如exclude = {"content"}。

3.2 Filter 主体:如何识别注解并决定是否过滤

import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.HandlerExecutionChain; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.method.HandlerMethod; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class XssFilter extends OncePerRequestFilter { private static final String XSS_CONFIG_KEY = "XSS_CLEAN_CONFIG"; private final RequestMappingHandlerMapping handlerMapping; public XssFilter(RequestMappingHandlerMapping handlerMapping) { this.handlerMapping = handlerMapping; } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { XssClean config = resolveConfig(request); if (config == null || !config.enabled()) { return true; } request.setAttribute(XSS_CONFIG_KEY, config); return false; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { XssClean config = (XssClean) request.getAttribute(XSS_CONFIG_KEY); XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(request, config); filterChain.doFilter(wrapper, response); } private XssClean resolveConfig(HttpServletRequest request) { try { HandlerExecutionChain chain = handlerMapping.getHandler(request); if (chain == null) { return null; } Object handler = chain.getHandler(); if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; XssClean methodAnno = handlerMethod.getMethodAnnotation(XssClean.class); if (methodAnno != null) { return methodAnno; } return handlerMethod.getBeanType().getAnnotation(XssClean.class); } } catch (Exception e) { // 静态资源、非标准 HandlerMethod 等场景直接放行 } return null; } }

shouldNotFilter是OncePerRequestFilter提供的扩展点,返回 true 表示跳过整个过滤器。这里我利用它做了两件事:查出当前请求对应的注解,决定是否过滤;把注解实例存到 request attribute 里,避免doFilterInternal里再查一遍。

RequestMappingHandlerMapping.getHandler(request)可能返回 null,也可能在处理静态资源时抛异常,所以必须包一层 try-catch,失败就放行。一个请求只会执行一次OncePerRequestFilter,所以 config 存 attribute 是安全的。

3.3 请求包装器:参数、请求头、请求体全覆盖

import javax.servlet.ReadListener; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import org.springframework.util.StreamUtils; public static class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { private final XssClean config; private byte[] rawBody; private byte[] cleanedBody; public XssHttpServletRequestWrapper(HttpServletRequest request, XssClean config) { super(request); this.config = config; } @Override public String getParameter(String name) { String value = super.getParameter(name); return isExcluded(name) ? value : XssFilter.cleanText(value, config.type()); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) { return null; } if (isExcluded(name)) { return values; } String[] cleaned = new String[values.length]; for (int i = 0; i < values.length; i++) { cleaned[i] = XssFilter.cleanText(values[i], config.type()); } return cleaned; } @Override public Map<String, String[]> getParameterMap() { Map<String, String[]> source = super.getParameterMap(); if (source == null || source.isEmpty()) { return source; } Map<String, String[]> target = new HashMap<>(source.size()); for (Map.Entry<String, String[]> entry : source.entrySet()) { String key = entry.getKey(); String[] value = entry.getValue(); if (isExcluded(key) || value == null) { target.put(key, value); continue; } String[] cleanedValue = new String[value.length]; for (int i = 0; i < value.length; i++) { cleanedValue[i] = XssFilter.cleanText(value[i], config.type()); } target.put(key, cleanedValue); } return target; } @Override public String getHeader(String name) { String value = super.getHeader(name); return isExcluded(name) ? value : XssFilter.cleanText(value, config.type()); } @Override public ServletInputStream getInputStream() throws IOException { if (rawBody == null) { byte[] original = StreamUtils.copyToByteArray(super.getInputStream()); this.rawBody = original; this.cleanedBody = XssFilter.cleanBody(original, config); } return new ServletInputStream() { private final ByteArrayInputStream in = new ByteArrayInputStream(cleanedBody); @Override public boolean isFinished() { return in.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 简单实现不走异步读取,保持同步读取即可 } @Override public int read() { return in.read(); } }; } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } private boolean isExcluded(String name) { if (name == null || config.exclude().length == 0) { return false; } return Arrays.asList(config.exclude()).contains(name); } }

这段代码覆盖了三种最常见的输入入口:表单参数走getParameter系列方法,请求头走getHeader,请求体走getInputStream。

有个细节要提醒:getInputStream和getReader只能调用一次,随意重写容易导致后续解析器拿不到数据。这里我把原始 body 缓存到字节数组,再基于清洗后的字节数组构造新的ServletInputStream,这样即使被多次读取也不会出问题。

如果你担心清洗请求头会影响 token、签名之类的信息,可以把getHeader那段删掉,或者只保留对自定义业务头生效的逻辑。大多数项目里 header 不会回显到页面,这段代码属于锦上添花。

3.4 JSON Body 清洗:逐字段递归处理

public static byte[] cleanBody(byte[] body, XssClean config) { if (body == null || body.length == 0) { return body; } String text = new String(body, StandardCharsets.UTF_8); String cleaned; try { ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(text); JsonNode result = cleanNode(root, config); cleaned = mapper.writeValueAsString(result); } catch (Exception e) { // 不是合法 JSON 时按普通文本处理 cleaned = cleanText(text, config.type()); } return cleaned.getBytes(StandardCharsets.UTF_8); } private static JsonNode cleanNode(JsonNode root, XssClean config) { if (root == null) { return null; } if (root.isObject()) { ObjectNode objectNode = (ObjectNode) root; List<String> fieldNames = new ArrayList<String>(); root.fieldNames().forEachRemaining(field -> fieldNames.add(field)); for (String field : fieldNames) { JsonNode child = objectNode.get(field); if (child.isTextual() && !isFieldExcluded(field, config)) { objectNode.put(field, cleanText(child.asText(), config.type())); } else if (child.isContainerNode()) { cleanNode(child, config); } } return objectNode; } if (root.isArray()) { ArrayNode arrayNode = (ArrayNode) root; List<JsonNode> items = new ArrayList<JsonNode>(); arrayNode.forEach(item -> items.add(item)); arrayNode.removeAll(); for (JsonNode item : items) { arrayNode.add(cleanNode(item, config)); } return arrayNode; } return root; }

为什么不能对整个 JSON 字符串直接做 HTML 转义?因为 JSON 结构中的双引号、反斜杠一旦被转义,JSON 解析直接失败。逐字段递归处理可以保证每个 value 被清洗,同时保留 JSON 结构完整,还方便按字段名排除。

这里用到了 Jackson 的ObjectMapper,因为 Spring Boot 项目默认已经依赖 Jackson,不需要额外引入。当前isFieldExcluded只对顶层字段名生效,如果你的业务有嵌套字段需要排除,可以把字段路径拼进递归参数里,比如user.nickname这样带点号的路径,实现思路是一样的。

3.5 清洗工具实现与正则策略

private static final Pattern SCRIPT_TAG_PATTERN = Pattern.compile("(?is)<\\s*/?\\s*(script|iframe|object|embed)[^>]*>.*?</\\s*\\1\\s*>|<\\s*/?\\s*(script|iframe|object|embed)[^>]*>"); private static final Pattern EVENT_ATTR_PATTERN = Pattern.compile("(?is)\\son[a-z]{2,}\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)"); private static final Pattern DANGEROUS_PROTOCOL_PATTERN = Pattern.compile("(?i)\\s*(javascript|vbscript|data)\\s*:"); public static String cleanText(String input, XssClean.XssType type) { if (input == null || input.isEmpty()) { return input; } if (type == XssClean.XssType.LOOSE) { String cleaned = input; cleaned = SCRIPT_TAG_PATTERN.matcher(cleaned).replaceAll(""); cleaned = EVENT_ATTR_PATTERN.matcher(cleaned).replaceAll(""); cleaned = DANGEROUS_PROTOCOL_PATTERN.matcher(cleaned).replaceAll("$1"); return cleaned; } StringBuilder sb = new StringBuilder(input.length()); for (int i = 0; i < input.length(); i++) { char c = input.charAt(i); switch (c) { case '&': sb.append("&amp;"); break; case '<': sb.append("&lt;"); break; case '>': sb.append("&gt;"); break; case '"': sb.append("&quot;"); break; case '\'': sb.append("&#39;"); break; default: sb.append(c); } } return sb.toString(); }

严格模式直接做实体转义,简单粗暴且有效。宽松模式用正则剥离危险内容,脚本标签、iframe、object、embed、事件属性、危险协议都在处理范围内。

这套正则不是完美的,比如嵌套变体和编码变体就无法保证全部拦截。想做更彻底的方案,需要换成白名单引擎或循环解码校验,后面我会专门分析边界。

4. 清洗策略与绕过分析

4.1 三种策略到底怎么选

方案处理方式适用场景主要风险
STRICT 严格模式对& < > " '做实体转义昵称、评论、备注、搜索词富文本会被转成纯文本
LOOSE 宽松模式移除标签、事件属性、危险协议简单富文本、含样式的业务字段正则永远追不上所有变体
Jsoup 白名单只放行指定标签和属性复杂富文本、文章内容依赖较重,需要维护白名单

大多数接口用 STRICT 就够了。比如昵称存&lt;script&gt;alert(1)&lt;/script&gt;,前端用文本方式展示时,用户看到的就是<script>alert(1)</script>这几个字符,不会执行。

LOOSE 适合你对样式有一定要求但不能接受脚本的场景。比如允许用户填<b>注意</b>,但不允许<img src=x onerror=alert(1)>。这种模式牺牲了一定的安全性来保留展示效果,需要配合测试用例不断补充正则。

如果你的项目有完整富文本编辑器,比如用户自己上传文章、粘贴来自网页的内容,那单靠过滤器的正则是扛不住的。老老实实引入白名单清洗引擎,让开发同学维护一份允许的标签和属性列表,这是最稳妥的方式。

4.2 常见绕过姿势与应对思路

XSS 的绕过方式千奇百怪,最常见的有这么几类。

大小写混合,比如<ScRiPt>。应对方式就是正则加CASE_INSENSITIVE,我在上面的正则里已经加了。

事件属性注入,比如<img src=x onerror=alert(1)>。这种利用的是 HTML 标签属性弱口令,应对方式是剥离所有on[a-z]+形式的事件属性,我的 EVENT_ATTR_PATTERN 就是干这个的。

危险协议构造,比如href="javascript:alert(1)"。应对方式是识别javascript:、data:、vbscript:这类协议头并做替换。

还有一种情况是编码变体,比如攻击者在 URL 里传%3Cscript%3E,Servlet 容器在getParameter阶段已经做了一次 URL 解码,所以过滤器在清洗时拿到的还是<script>,能正常拦截。如果遇到二次编码、第三方网关再编码的情况,就需要在你的过滤链前再加一道解码规则。

4.3 这套方案不适用哪些场景

不是所有接口都适合无脑加@XssClean。

接口需要接收代码片段、Markdown 原文、JSON 模板等特殊内容的场景,清洗可能会破坏数据结构,导致业务出错。这类接口建议在注解上设置enabled = false,或者不加注解,单独在业务层处理。

SQL 拼接场景也别指望这个过滤器。它只处理 XSS 相关的脚本注入,不处理 SQL 注入。SQL 注入请老老实实用参数绑定,这不是一个过滤器能包办的事。同理,文件上传、目录穿越、越权访问这些问题,也需要在各自层面单独防护。

我心里一直有个底线:这个过滤器是入口防线之一,不是全部安全方案。接口设计上坚持“输入不可信、输出不裸奔”,比任何一个过滤器都重要。

5. 在项目里 30 分钟接完的实操步骤

5.1 接入步骤清单

第一步,把注解类XssClean放到项目的 common 包或者安全相关包下。

第二步,把过滤器类XssFilter放到能被 Spring Boot 扫描到的包路径下。类上有@Component,启动后自动注册。

第三步,在需要防 XSS 的 Controller 方法或 Controller 类上添加@XssClean。

第四步,启动项目,用 curl 或者 Postman 发一条带恶意脚本的请求验证效果。

整个过程不用改配置文件,不用设置 filter-mapping,不用动了不起的全局拦截链条。

5.2 Controller 示例与两种提交方式验证

假设有一个保存用户信息的接口:

@RestController @RequestMapping("/api/user") public class UserController { @PostMapping("/save") @XssClean(type = XssClean.XssType.STRICT) public Result save(@RequestBody UserDTO user) { userService.save(user); return Result.ok(); } @PostMapping("/save-form") @XssClean(type = XssClean.XssType.STRICT) public Result saveForm(UserDTO user) { userService.save(user); return Result.ok(); } }

用 JSON 方式提交:

curl -X POST http://localhost:8080/api/user/save \ -H 'Content-Type: application/json' \ -d '{"nickname":"<script>alert(1)</script>","bio":"<b>hi</b>"}'

如果你在接口里把收到的nickname打印出来,会是:

{"nickname":"&lt;script&gt;alert(1)&lt;/script&gt;","bio":"&lt;b&gt;hi&lt;/b&gt;"}

用表单方式提交:

curl -X POST http://localhost:8080/api/user/save-form \ -d "nickname=<script>alert(1)</script>"

同样能清洗成功。两条链路的区别是:表单参数走的是getParameter系列,JSON body 走的是getInputStream加 Jackson 递归清洗。

5.3 注解的三种典型用法

类上统一开:

@XssClean(type = XssClean.XssType.STRICT) @RestController @RequestMapping("/api/user") public class UserController { }

这样类下的所有接口都会被清洗。如果你想类里某个接口不开:

@XssClean(enabled = false) @PostMapping("/raw") public Result raw(@RequestBody RawDTO dto) { }

这种局部关闭的写法,适合类似文章转存储这类需要保留原始内容的场景。

排除种子字段:

@XssClean(type = XssClean.XssType.LOOSE, exclude = {"content"}) @PostMapping("/article") public Result saveArticle(@RequestBody ArticleDTO dto) { }

这里的content字段会原样进入业务层,其他字段走宽松清洗。

6. 踩坑记录与快速排查

6.1 拦截器里包装 request 的误区

我最早想在 HandlerInterceptor 里实现,写完发现 Controller 拿到的还是原始参数。原理前面已经说过,但这里值得再强调一次。preHandle方法里的 request 参数只是局部引用,你替换它不会影响 DispatcherServlet 后续的数据绑定。

这个坑还有个变体:有人把新包装器塞进RequestContextHolder,以为能骗过 Spring 的请求上下文。实测无效,因为 Spring MVC 数据绑定是从 DispatcherServlet 的方法参数里取 request,而不是从上下文里找。所以要做请求内容替换,请认准 Filter 层。

6.2 JSON 请求为什么没被清洗

如果你只重写了getParameter,很容易碰到一个测试结果:表单请求清洗生效了,JSON 请求完全没变化。原因很简单,@RequestBody的 JSON 解析走的是getInputStream,根本不经过getParameter。

很多人第一次写 XSS 过滤器就漏在这里。解决方案就是前面代码里的cleanBody,把getInputStream重写掉,读取原始 body、清洗、再输出。

注意getInputStream和getReader只能读取一次,必须把原始 body 缓存住,否则 Controller 解析时可能读到空内容,接口直接报 400。

6.3 存储转义后前端乱码的坑

有次联调,前端反馈说昵称显示成了&lt;script&gt;,而不是正常的文本内容。排查下来是后端存的是转义后的字符串,模板引擎在渲染时又做了一次转义,双重转义导致界面出现了乱码。

这个问题本质上是团队约定问题。后端存转义后的安全字符串,前端在文本插值场景不应该再做二次转义;如果前端必须用 v-html 渲染富文本,后端只能输出已经过白名单清洗的内容。上这套过滤器之前,建议先跟前端同步清楚存储和渲染的策略,不然联调时容易扯皮。

6.4 上传接口被误伤的坑

加了过滤器后,有同事反馈文件上传接口失败,看日志发现二进制内容在解析时被当成文本处理了。这个问题发生在cleanBody里:上传接口的 body 是 multipart 格式,包含二进制文件流,直接用 UTF-8 转字符串再清洗,很容易把文件内容搞坏。

处理方式是先判断isMultipartContent(request),如果是 multipart 请求,跳过 body 清洗,只对普通表单参数做过滤。文件上传本身有独立的安全要求,比如扩展名校验、大小限制、病毒扫描,这些不应该和 XSS 清洗混在一起。

6.5 快速排查速查表

现象可能原因处理方式
接口参数没被清洗注解没标上,或 Filter 没被扫描检查类路径,确认注解标注位置
JSON 请求没被清洗只重写了 getParameter,没处理 getInputStream参考 3.4 实现 body 清洗
清洗后接口报 400对整串 JSON 做了转义,导致 JSON 结构损坏改用逐字段递归清洗
上传文件报错Filter 把二进制 body 当文本处理增加 multipart 判断,跳过 body 清洗
前端显示转义字符串存储已转义,模板又转义一次与前端约定渲染策略
未标注的接口也被过滤类上标注了 @XssClean检查注解是标在类还是方法上

我在实际使用中最大的体会是:安全方案能不能落地,往往不取决于技术深度,而取决于接入成本和对既有接口的侵入程度。一个注解加一个类,团队里后端同学看一次就会用,不用在几十个 Controller 里做重复劳动。

最后再分享一个小技巧:先把这套过滤器接到测试环境上跑几天,把业务接口的测试用例过一遍,重点观察两个点——被exclude的字段是否真的原样保留,以及前端展示有没有因为转义策略变化而出现异常。确认没问题之后,再逐步推广到生产环境,而不是上线当天一次性全量启用。这样能最大程度减少“过滤误伤”带来的线上事故。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进