先说一个我前阵子遇到的真实案例。我们某个管理后台的“个人备注”功能被人塞了一行<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>变成<script>,适合昵称、备注等纯文本字段;宽松模式只剥离 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("&"); break; case '<': sb.append("<"); break; case '>': sb.append(">"); break; case '"': sb.append("""); break; case '\'': sb.append("'"); break; default: sb.append(c); } } return sb.toString(); }严格模式直接做实体转义,简单粗暴且有效。宽松模式用正则剥离危险内容,脚本标签、iframe、object、embed、事件属性、危险协议都在处理范围内。
这套正则不是完美的,比如嵌套变体和编码变体就无法保证全部拦截。想做更彻底的方案,需要换成白名单引擎或循环解码校验,后面我会专门分析边界。
4. 清洗策略与绕过分析
4.1 三种策略到底怎么选
| 方案 | 处理方式 | 适用场景 | 主要风险 |
|---|---|---|---|
| STRICT 严格模式 | 对& < > " '做实体转义 | 昵称、评论、备注、搜索词 | 富文本会被转成纯文本 |
| LOOSE 宽松模式 | 移除标签、事件属性、危险协议 | 简单富文本、含样式的业务字段 | 正则永远追不上所有变体 |
| Jsoup 白名单 | 只放行指定标签和属性 | 复杂富文本、文章内容 | 依赖较重,需要维护白名单 |
大多数接口用 STRICT 就够了。比如昵称存<script>alert(1)</script>,前端用文本方式展示时,用户看到的就是<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":"<script>alert(1)</script>","bio":"<b>hi</b>"}用表单方式提交:
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 存储转义后前端乱码的坑
有次联调,前端反馈说昵称显示成了<script>,而不是正常的文本内容。排查下来是后端存的是转义后的字符串,模板引擎在渲染时又做了一次转义,双重转义导致界面出现了乱码。
这个问题本质上是团队约定问题。后端存转义后的安全字符串,前端在文本插值场景不应该再做二次转义;如果前端必须用 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的字段是否真的原样保留,以及前端展示有没有因为转义策略变化而出现异常。确认没问题之后,再逐步推广到生产环境,而不是上线当天一次性全量启用。这样能最大程度减少“过滤误伤”带来的线上事故。