资讯详情

资讯详情

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 srcx onerroralert(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 MapString, String[] getParameterMap() { MapString, String[] parameterMap super.getParameterMap(); MapString, String[] cleanMap new HashMap(parameterMap.size()); for (Map.EntryString, 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/jsonRequestBody通过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等关键词实践证明这是一条死胡同攻击者只需使用scrscriptipt、大小写混写、HTML实体编码、反斜杠变体就能绕过正则。最终我引入了Jsoup的clean方法它通过白名单机制只保留显式允许的标签和属性其它全部剔除这里重点说下常用配置。dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.16.2/version /dependencypublic 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;scriptgt;浏览器拿到只会当普通文本展示。但总有开发为了方便直接使用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, scriptalert(1)/script)) .andExpect(status().isOk()) .andExpect(content().string(not(containsString(script)))); } Test void shouldCleanBodyInJsonRequest() throws Exception { String json {\content\:\img srcx onerroralert(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拒绝执行的明确警告这种反馈比代码里任何日志都直观。我常用的一组手动测试载荷在搜索框输入scriptalert(document.domain)/script如果页面弹窗或者出现CSP报错说明防御存在缺口输入img/srcx/onerroralert(1)测试属性事件输入a hrefjavascript: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甚至>
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →