ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot四层XSS防御实战:过滤器+包装器+CSP+输出编码

Spring Boot四层XSS防御实战:过滤器+包装器+CSP+输出编码

我处理项目里那个"搜索框变成按钮"的Bug,才真正理解XSS不是文档里的一句名词,而是能在几分钟内把Cookie、页面内容、用户操作全部劫走的入口。当时用Spring Boot做的后台管理系统,前端同学随手往标题栏输入了一段带尖括号的内容,页面上直接把标签渲染成了DOM,那一刻所有人都意识到,我们的接口对"代码注入"基本是裸奔。这篇文章就记录我当时从零搭建的"过滤器+请求包装器+输出编码+CSP响应头"四层XSS防御方案,全流程可复现,相关代码都直接贴在章节里,适合正在用Spring Boot开发接口、后台系统或者前后端分离项目的开发者参考。

我记得很清楚,最开始想得很简单:在Controller里对每个参数做replace,把所有尖括号去掉不就行了?结果写完发现自己掉进了一个巨大的坑:接口一多,参数一杂,漏掉一个字段就前功尽弃,而且replace方案对大小写混写、HTML实体编码、属性内联事件根本防不住。后面逐步演化成一套组合方案,核心思路就一句话:不能把安全逻辑散落在业务代码里,要用统一的入口做清洗,再在出口做编码,最后用浏览器安全策略兜底。

1. 理解XSS攻击的实际场景:防御方案的设计起点

1.1 三类XSS攻击与真实业务映射

在做方案之前,我先花了一晚上把项目里可能出现XSS的位置全过了一遍,这时候才发现"哪里会被打"比"怎么防御"更重要。存储型XSS最容易出现在后台系统的评论、公告、商品描述这类入口,攻击者把脚本写进数据库,页面渲染时自动执行;反射型XSS则集中在搜索框、URL参数、跳转参数这类需要回显的地方,攻击者构造一个恶意链接,引诱用户点击,参数里的脚本跟着请求进入后端再原样返回页面;DOM型XSS稍微隐蔽一点,攻击逻辑完全在前端JavaScript里运转,比如从location.hash里取参数拼进DOM,后端甚至看不到完整攻击载荷。

从实际问题来看,如果业务还在用服务端渲染模板(Thymeleaf、JSP),反射型和存储型是重灾区;如果业务已经是前后端分离,后端只提供JSON接口,那么攻击者主要盯的是JSON请求体里的字段,这些数据被前端取走后如果直接插入页面,同样形成存储型XSS。Spring Boot本身没有内置一个"一键防XSS"开关,加上很多团队默认"我们只返回JSON,不会渲染HTML",这个认知错位就是漏洞的温床。防御方案要从这个真实风险出发,而不是从"我觉得别人应该不会这么传"出发。

1.2 两个常被忽略的隐蔽攻击入口

第一个隐蔽入口是HTTP请求头。服务端记录日志、客服系统展示用户设备信息、管理后台展示访客IP归属,都可能把User-Agent、Referer、X-Forwarded-For原样展示在页面上。攻击者只要把脚本放在UA里,管理员一看日志就中招,这种场景在小团队里非常常见,因为大家默认"请求头是浏览器生成的,不会有恶意内容"。

第二个隐蔽入口是文件上传后的内容展示。很多系统上传Excel导入数据,之后把每一行的内容回显到页面上,这些内容是团队自己上传的,潜意识里觉得安全,但Excel单元格里完全能写入<img src=x onerror=alert(1)>,导入后再一展示就触发。我在方案里把这些隐蔽入口也纳入清洗范围,标准很简单:只要数据要从后端流向用户浏览器,就必须经过统一的输出编码或输入清洗,没有任何字段可以豁免。

2. 用过滤器与请求包装器构建统一入口防线

2.1 为什么选Filter而不是拦截器

第一版方案我没考虑拦截器,因为Spring的HandlerInterceptor依赖Spring MVC的HandlerMapping,这意味着静态资源、非Controller路径、部分异常处理路径根本不会进入拦截器。而Servlet的Filter位于整个请求处理链的最前端,Container加载它的时候,请求还没有被映射到任何Controller,这个位置决定了它可以用统一的方式处理所有进来的请求,无论是动态接口、静态资源,还是表单提交。

我用的实现方式是自定义一个XssFilter,在web应用启动时注入到过滤器链的最前面:通过@Order(Ordered.HIGHEST_PRECEDENCE)保证它先于其他过滤器执行。Filter的核心作用不是自己完成所有清洗,而是把原始请求包装成一个"安全请求对象",后续的Controller、Service拿到的都是经过处理后的数据。这样业务代码完全不用关心XSS防御,新增接口也不会因为"忘记过滤"而留漏洞,这是方案能长期维持的根本原因。

2.2 请求包装器的完整实现

过滤器本身很薄,真正干活的是HttpServletRequestWrapper。包装器覆盖了getParameter、getParameterValues、getParameterMap这几个最常见的参数读取入口,做到无论代码里用哪种方式取参数,得到的都是清洗后的值。先看基础版本:

public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { return XssCleanUtil.clean(super.getParameter(name)); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) { return null; } String[] cleanValues = new String[values.length]; for (int i = 0; i < values.length; i++) { cleanValues[i] = XssCleanUtil.clean(values[i]); } return cleanValues; } @Override public Map<String, String[]> getParameterMap() { Map<String, String[]> parameterMap = super.getParameterMap(); Map<String, String[]> cleanMap = new HashMap<>(parameterMap.size()); for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) { String[] values = entry.getValue(); String[] cleanValues = new String[values.length]; for (int i = 0; i < values.length; i++) { cleanValues[i] = XssCleanUtil.clean(values[i]); } cleanMap.put(entry.getKey(), cleanValues); } return cleanMap; } }

这里有一个在IndexPage描述不太清楚但实际非常关键的细节:getParameterMap返回的Map通常是不可修改的内部视图,直接修改会报异常,所以必须复制一份新Map。我在第一版踩过这个坑,后来才改成复制的方式。

2.3 JSON请求体清洗的第三种方案

上面包装器解决的是查询参数和表单字段,但现代接口大量使用Content-Type: application/json,RequestBody通过getInputStream()读取,这个时候Filter处理起来就麻烦很多。这个问题的根源在于请求流只能读一次,而Controller的@RequestBody也需要读流,两边会冲突。

在我的实际项目里,我采用的方案是对getInputStream()做缓存包装:包装器第一次初始化时把请求体完整读出来,清洗一遍后存到内存里,之后Controller再读的时候,读的是清洗后的缓存流。这样处理完整保留了原有的JSON结构,又保证了防腐逻辑的统一。示例代码如下:

public class XssBodyRequestWrapper extends HttpServletRequestWrapper { private final String cleanBody; public XssBodyRequestWrapper(HttpServletRequest request) throws IOException { super(request); String rawBody = request.getReader().lines() .collect(Collectors.joining(System.lineSeparator())); this.cleanBody = XssCleanUtil.cleanBody(rawBody); } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new StringReader(cleanBody)); } @Override public ServletInputStream getInputStream() throws IOException { byte[] bytes = cleanBody.getBytes(StandardCharsets.UTF_8); ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); return new ServletInputStream() { @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 保持Servlet InputStream接口完整性 } @Override public int read() { return byteArrayInputStream.read(); } }; } }

这里还有个容易忽略的要点:当请求体经过清洗后,如果包裹的接口调用方用了流式读取,那么必须确保返回的ServletInputStream不是null而且在getInputStream()被调用过之后能正常工作,否则会出现IllegalStateException。我最后上线前把所有接口都回归了一遍,确保getInputStream情况正常。

2.4 清洗引擎的设计与依赖选型

清洗引擎是这套方案最重要的部分。我曾经尝试自己写正则表达式来替换<script>、javascript:、onerror等关键词,实践证明这是一条死胡同:攻击者只需使用<scr<script>ipt>、大小写混写、HTML实体编码、反斜杠变体就能绕过正则。最终我引入了Jsoup的clean方法,它通过白名单机制只保留显式允许的标签和属性,其它全部剔除,这里重点说下常用配置。

<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.16.2</version> </dependency>
public class XssCleanUtil { private static final Whitelist WHITELIST = Whitelist.relaxed() .addProtocols("a", "href", "http", "https") .addProtocols("img", "src", "http", "https"); public static String clean(String value) { if (value == null || value.isEmpty()) { return value; } return Jsoup.clean(value, WHITELIST); } public static String cleanBody(String body) { if (body == null || body.isEmpty()) { return body; } // JSON字符串里嵌套的HTML标签、属性都会被清洗,同时保留JSON结构 return Jsoup.clean(body, WHITELIST); } }

使用Jsoup需要注意的一个实际问题:Whitelist.relaxed()会保留很多标签和属性,如果业务场景是纯文本内容,我更建议直接使用Whitelist.none(),这意味着所有HTML标签都会被剥除,只留下纯文本;如果业务里确实需要富文本编辑器的内容,就需要配合addAttributes、addProtocols做细粒度配置,比如只允许img的src使用http和https协议,禁止javascript:伪协议。项目里的富文本场景我单独走了一条豁免路径,后面在常见问题里细讲。

3. 出口防线与浏览器安全策略:CSP与输出编码

3.1 Content-Security-Policy响应头的配置

入口清洗不是万能的,攻击者还可能通过其它渠道把危险内容投放到页面,所以必须在浏览器端划定安全边界。CSP(内容安全策略)是一种由服务器下发的策略指令,它明确告诉浏览器"哪些来源的脚本可以执行、哪些来源的资源可以加载"。一旦策略生效,即使页面里被注入了<script>标签,浏览器也会拒绝执行。

在Spring Boot里添加CSP响应头最常见的方式是扩展WebSecurityConfigurerAdapter或使用较新的SecurityFilterChain配置,在响应头中加入Content-Security-Policy。一个敞开的初始化版本配置如下:

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'") ) ); return http.build(); } }

注意script-src 'self'禁止了页面里内联的<script>代码,这是最严格的状态。实际项目里如果前端写了内联脚本或者用了老旧的第三方组件,会把所有脚本执行卡死,这时候可以把这个策略改成script-src 'self' 'unsafe-inline',但这就留下了内联注入的口子。更好的做法是配合随机Nonce值:每次响应生成一个随机的Nonce,只有带这个Nonce的内联脚本才被允许执行。Spring Security支持直接用Nonce配置,我在后续项目中也切换到了这个更高等级的模式。

3.2 Thymeleaf的输出编码与th:text对比

输出编码是最后一个重要环节。在Spring Boot里使用Thymeleaf做页面渲染时,th:text的默认行为就是HTML转义,比如把<script>转成&lt;script&gt;,浏览器拿到只会当普通文本展示。但总有开发为了"方便"直接使用th:utext或[[ ${content} ]],这两个方式输出的是未经转义的HTML,一旦字段内容来自用户输入,就相当于亲手为XSS铺路。

我这里给团队定了一条铁律:凡是展示用户可编辑内容的地方,一律使用th:text,禁止使用th:utext。如果确实需要让富文本编辑器内容正常渲染,就必须配合入口清洗白名单,保证经过Jsoup清洗后只保留安全标签,再使用th:utext渲染,因为这个内容已经是"消毒后"的产物。还有一个容易被忽视的隐藏问题是前后端分离架构下,前端框架的插值语法默认会转义,但v-html和dangerouslySetInnerHTML这类接口是明晃晃的例外,需要前端同学在项目规范里明确禁止或同样配套清洗。

3.3 Cookie安全属性不可忽略

XSS攻击的核心目的之一是窃取登录态Cookie,而HttpOnly属性能从源头阻断脚本读取Cookie。Java后端设置Cookie时加上HttpOnly,浏览器端JavaScript就无法通过document.cookie获得这个值,这等于在XSS陷阱上面加了个保险锁。

Spring Boot里设置HttpOnly Cookie的方式很简单,在Spring Security配置里使用http.csrf()的同时,给Session Cookie加上HttpOnly、Secure属性即可。如果使用自定义的Cookie写入逻辑,需要主动设置cookie.setHttpOnly(true)、cookie.setSecure(true)以及合理的SameSite属性。我在项目里验证过一个场景:攻击者已经通过某个输入点注入了脚本,但尝试读取会话Cookie时得到的只是一个空壳,这个不起眼的属性确实让事故等级直接降了一档。

4. 从测试到落地:验证防御方案是否真的生效

4.1 用MockMvc编写自动化测试

安全方案做完了不能靠直觉判断"应该没问题",必须用测试把每个防御点钉死。我推荐用Spring Boot自带的MockMvc,它对Filter链的模拟非常真实,可以同时验证输入清洗和对响应头的断言。

@SpringBootTest @AutoConfigureMockMvc class XssDefenseTests { @Autowired private MockMvc mockMvc; @Test void shouldCleanScriptTagInQueryParameters() throws Exception { mockMvc.perform(get("/search") .param("keyword", "<script>alert(1)</script>")) .andExpect(status().isOk()) .andExpect(content().string(not(containsString("<script>")))); } @Test void shouldCleanBodyInJsonRequest() throws Exception { String json = "{\"content\":\"<img src=x onerror=alert(1)>\"}"; mockMvc.perform(post("/api/comment") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content").value(not(containsString("onerror")))); } @Test void shouldAddContentSecurityPolicyHeader() throws Exception { mockMvc.perform(get("/index")) .andExpect(header().string("Content-Security-Policy", containsString("script-src"))); } }

第一版测试跑通后,我又加了一批"绕过尝试"用例,覆盖大小写混写、HTML实体编码、属性事件、外链脚本这些常见攻击方式。这些用例的价值不仅仅是验证功能,更是给后续维护的人传递一个信息:这些攻击路径是被明确防御过的,改动这部分代码要小心。

4.2 用浏览器开发工具做手动验证

自动化测试之外,强烈建议开发者用浏览器开发工具手动做一轮全链路验证。打开页面的开发者工具,在Console里查看是否有CSP报错,在Network面板查看响应头里是否真的带上了Content-Security-Policy,再切换到Sources面板确认页面源码里<script>标签是否被转义。CSP如果配置生效,即使页面上出现了内联脚本,Console中也会输出"CSP拒绝执行"的明确警告,这种反馈比代码里任何日志都直观。

我常用的一组手动测试载荷:在搜索框输入<script>alert(document.domain)</script>,如果页面弹窗或者出现CSP报错,说明防御存在缺口;输入<img/src=x/onerror=alert(1)>测试属性事件;输入<a href="javascript:alert(1)">test</a>测试伪协议。这组载荷基本能把三种主流绕过路径都覆盖到。

4.3 清理存量脏数据

防御方案上线后,数据库里已经存在的脏数据不会自动变干净。我习惯的做法是:针对用户可编辑的核心字段(昵称、个人简介、评论内容)写一个一次性清理脚本,把历史数据用Jsoup的白名单机制重新清洗一遍,然后再把清洗后的数据更新回库。这个步骤很笨但非常有必要,否则新方案只保护新写入的数据,存量数据一旦被翻出来展示,漏洞依然存在。

另外,线上接口的日志也要检查一遍,确保日志系统没有把包含原始攻击载荷的请求体原样输出到文件。有些监控系统甚至会把带脚本的内容通过邮件报警发出来,技术同事收到电子邮件打开时又中一次招。安全防御必须形成闭环,从请求、存储、输出、日志、监控每一个环节都要覆盖。

5. 实战中的常见坑与排查经验

5.1 过滤器导致JSON请求全部失效

这是这套方案上线那天最惨痛的教训。某跨平台系统采用整体过滤方案时,只加了查询参数的包装器,没有处理请求体缓存流,结果所有POST接口的@RequestBody都拿到空对象,前端全线报错。排查了半个多小时才发现问题:包装器默认的getInputStream()直接透传原始流,Filter和Controller之间有个先后读流顺序,谁先读了,后面的就是空的。

这个问题的标准解法就是我在2.3节写的XssBodyRequestWrapper,把请求体缓存一次,后续的读取全部指向缓存。需要额外注意的一点是,这个缓存方案会占用内存,对于超大文件上传类的接口,建议直接从过滤器中排除,或者设定只处理Content-Type为application/json的请求,避免无谓的性能损耗。

5.2 富文本与XSS防御的冲突

后台系统几乎都有富文本编辑器的需求,这也是最容易和过滤器爆发冲突的地方。团队的编辑器生成的HTML里有style、class、id甚至>

RELATED READING

延伸阅读

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