1. 从一个真实案例说起:为什么你的Springboot项目需要关注XSS去年帮一个朋友排查他负责的社区论坛项目,功能都正常,但某天运营反馈说用户列表页面会莫名其妙弹出一个登录框,输入任何账号密码都提示错误。一开始以为是前端组件出了问题,排查了半天前端代码没发现异常。后来抓包看接口返回数据,发现某个用户的昵称字段里藏了一段<script>标签,直接渲染到了页面上。这就是典型的存储型XSS——攻击者把恶意脚本通过用户输入接口存进了数据库,其他用户浏览页面时脚本自动执行。XSS(跨站脚本攻击)在Web安全领域常年位居OWASP Top 10前列,Springboot作为目前Java后端开发的主流框架,天然面临这类风险。很多人觉得Springboot已经帮我们做了很多安全防护,但XSS这件事,框架本身并不会自动帮你兜底。这篇文章我会从实际项目出发,把XSS的类型、Springboot项目中常见的漏洞入口、修复方案的选择逻辑、具体代码实现、以及踩过的坑,完整地梳理一遍。不管你是刚接触安全的新手,还是已经写过几年代码的老手,应该都能从中找到可以直接用的东西。2. XSS漏洞的本质与三种类型拆解2.1 浏览器如何被"骗"执行了不该执行的脚本要理解XSS,先得搞清楚浏览器是怎么处理HTML的。浏览器解析HTML时,遇到<script>标签就会执行里面的JavaScript代码,这是设计如此。问题在于,如果这段<script>不是开发者写的,而是用户输入的内容被原样输出到了页面上,浏览器分不清这是"开发者写的"还是"用户输入的",它只管执行。打个比方:你开了一家餐厅,菜单是固定的。但有个顾客偷偷把自己的菜名写在了菜单板上,其他顾客点了这道"菜",后厨就按照顾客写的奇怪配方去做,结果做出来的东西可能把厨房炸了。XSS就是这个逻辑——用户输入的内容被当成了页面代码的一部分。XSS的核心成因可以归结为一句话:不可信的数据未经处理就进入了HTML输出上下文。注意这里的关键词是"输出上下文",因为不同的上下文(HTML标签内、属性内、JavaScript代码内、CSS内、URL内)需要不同的处理方式。2.2 反射型、存储型、DOM型:三种XSS的区别与危害XSS通常分为三类,理解它们的区别对修复方案的选择至关重要。反射型XSS是最常见的一种。攻击者构造一个带有恶意参数的URL,诱导用户点击。用户点击后,恶意脚本作为响应内容"反射"回浏览器并执行。比如搜索功能,用户搜索的关键词会显示在结果页面上,如果没做处理,搜索<script>alert(1)</script>就会弹窗。反射型XSS的特点是恶意脚本不存储在服务器上,每次都需要用户主动点击恶意链接。存储型XSS危害最大。恶意脚本被存储到服务器(数据库、文件、缓存等),当其他用户访问包含这段数据的页面时自动执行。典型场景包括用户昵称、评论内容、留言板、个人简介等。存储型XSS不需要用户点击特定链接,只要访问正常页面就会中招,影响范围可能是所有访问该页面的用户。DOM型XSS比较特殊,恶意脚本的注入和触发完全发生在浏览器端的JavaScript中,不经过服务器。比如前端代码用location.hash获取URL片段并直接插入到页面中,攻击者构造#<img src=x onerror=alert(1)>就能触发。这类XSS的修复重点在前端JavaScript代码,后端防护措施对它无效。类型数据流向是否经过服务器危害程度典型场景反射型请求→响应→浏览器执行是中搜索、错误提示存储型请求→数据库→响应→浏览器执行是高评论、昵称、简介DOM型URL→前端JS→浏览器执行否中高前端路由、动态渲染2.3 为什么Springboot项目特别容易中招Springboot项目容易出XSS问题,主要有几个原因。第一,Springboot默认使用Thymeleaf或JSP作为模板引擎,模板引擎的th:utext(不转义文本)和th:text(转义文本)如果选错了,直接就是XSS漏洞。第二,现在前后端分离架构流行,后端返回JSON数据,前端用Vue/React渲染,很多人以为JSON接口不会有XSS问题,但如果前端用了v-html或dangerouslySetInnerHTML,照样中招。第三,Springboot的@RequestParam和@RequestBody接收参数时不会自动做XSS过滤,需要开发者自己处理。还有一个容易被忽视的点:Springboot项目经常集成富文本编辑器,比如UEditor、WangEditor、TinyMCE等。富文本编辑器允许用户输入HTML标签,如果后端不做白名单过滤,攻击者可以直接提交带有<script>或<img onerror>的内容,存储型XSS就来了。3. Springboot项目中XSS漏洞的常见入口排查3.1 接口层:哪些参数最容易成为攻击入口在Springboot项目中,XSS漏洞的入口主要集中在接收用户输入的地方。我梳理了一下常见的入口点:@RequestParam接收的查询参数:比如搜索关键词、分页参数、筛选条件等。这些参数经常被直接返回给前端展示。@PathVariable接收的路径变量:比如/user/{username},如果username包含恶意脚本,在某些场景下会被渲染。@RequestBody接收的JSON数据:用户提交的表单、评论、文章内容等。这是存储型XSS的主要来源。文件上传的文件名:上传文件时,文件名可能包含恶意脚本,如果前端展示文件名时没做处理,也会触发XSS。HTTP请求头:比如User-Agent、Referer等,如果后端把这些头信息记录并展示在管理后台,也可能成为XSS入口。排查的时候,我一般会从Controller层开始,把所有接收用户输入的方法列出来,逐个检查这些输入最终去了哪里。如果输入被直接返回给前端(比如return userInput;),或者被存储到数据库后又在页面上展示,那就是高风险点。3.2 模板渲染层:Thymeleaf和JSP的转义陷阱Thymeleaf是Springboot官方推荐的模板引擎,它默认会对th:text的内容进行HTML转义。比如<span th:text="${userInput}">,如果userInput是<script>alert(1)</script>,渲染出来会变成<script>alert(1)</script>,浏览器不会执行。但问题出在th:utext上。th:utext表示"unescaped text",不转义。有些开发者为了让富文本内容正常显示,会使用th:utext,这就打开了XSS的大门。我见过不少项目,文章详情页用th:utext渲染文章内容,但后端没有对文章内容做任何过滤,攻击者提交一篇带有恶意脚本的文章,所有阅读这篇文章的用户都会中招。JSP的情况类似,<%= %>默认不转义,<c:out>默认转义。很多老项目用JSP,开发者习惯用<%= %>直接输出,XSS漏洞一抓一大把。提示:Thymeleaf 3.x版本中,th:utext和th:text的区别一定要搞清楚。除非你非常确定内容已经过严格过滤,否则永远用th:text。3.3 前端渲染层:JSON接口返回后的二次渲染风险前后端分离项目中,后端返回JSON,前端负责渲染。很多人觉得JSON接口不会有XSS问题,因为JSON里的<script>标签不会被浏览器执行。这个想法只对了一半——JSON本身确实不会执行脚本,但前端拿到数据后怎么渲染才是关键。如果前端用Vue的{{ }}插值表达式,Vue会自动转义HTML,安全。但如果用了v-html指令,Vue不会转义,直接插入HTML,XSS就来了。React的dangerouslySetInnerHTML同理。还有一些前端框架的富文本渲染组件,默认就是插入HTML,如果后端没过滤,前端也没做处理,漏洞就存在。所以修复XSS不能只靠后端,前端渲染方式也要检查。但作为后端开发者,我们能做的是在数据出口处做好过滤和转义,尽量让前端拿到的是"干净"的数据。4. XSS修复方案选型:从过滤器到富文本白名单4.1 方案对比:转义、过滤、白名单,到底选哪个修复XSS的思路主要有三种:输出转义、输入过滤、白名单过滤。每种方案适用的场景不同,实际项目中往往是组合使用。输出转义是在数据输出到HTML页面时,把特殊字符转换成HTML实体。比如<转成<,>转成>,"转成"。这种方案的好处是不影响数据的存储,原始数据保持原样,只在展示时转义。缺点是需要在每个输出点都做处理,容易遗漏。Thymeleaf的th:text和JSP的<c:out>就是这种思路。输入过滤是在接收用户输入时,直接过滤掉或替换掉危险字符。比如把<script>直接删掉。这种方案的好处是数据入库时就是干净的,后续使用不需要再处理。缺点是可能误伤正常内容,比如用户真的想输入"1<2"这样的数学表达式,过滤后变成"12",数据就错了。白名单过滤是只允许特定的HTML标签和属性通过,其他一律过滤。这种方案适合富文本场景,比如允许<p>、<strong>、<a>等标签,但禁止<script>、<iframe>、on*事件属性等。白名单过滤比黑名单过滤安全得多,因为黑名单永远列不全所有危险标签和属性。方案原理优点缺点适用场景输出转义输出时转义特殊字符不影响原始数据需覆盖所有输出点普通文本展示输入过滤接收时过滤危险字符数据入库即干净可能误伤正常内容简单文本输入白名单过滤只允许安全标签通过安全性高配置复杂富文本内容4.2 为什么我最终选择了"过滤器+白名单"的组合方案在实际项目中,我最终采用的方案是:全局XSS过滤器 + 富文本白名单过滤。具体来说,对于普通接口参数,通过过滤器统一做HTML转义或危险字符过滤;对于富文本内容,使用白名单库(比如OWASP Java HTML Sanitizer或Jsoup)做标签白名单过滤。选择这个组合方案的理由有几个。第一,全局过滤器可以覆盖大部分接口,减少遗漏。第二,白名单过滤可以精确控制富文本内容,既保证安全又不影响正常功能。第三,Springboot的Filter机制很容易集成,不需要改动太多现有代码。全局过滤器的实现思路是:拦截所有请求,对请求参数、请求体、请求头进行XSS过滤,把处理后的请求传递给后续的Controller。这样Controller拿到的就是已经过滤过的数据,不需要每个方法都手动处理。但全局过滤器也有坑。比如文件上传接口,请求体是二进制流,过滤器如果去读取请求体,会导致流被消费掉,后续Controller读不到数据。所以过滤器需要排除文件上传等特殊接口。另外,过滤器的过滤规则如果太严格,可能会把正常内容也过滤掉,需要根据业务场景调整。4.3 富文本场景的特殊处理:既要安全又要保留格式富文本是XSS修复中最棘手的场景。用户需要输入加粗、斜体、链接、图片等格式,这些都需要HTML标签来实现。如果全部转义,富文本就变成了纯文本,功能就废了。如果全部放行,XSS漏洞就来了。我的做法是使用OWASP Java HTML Sanitizer,定义一个白名单策略,只允许安全的标签和属性通过。比如:PolicyFactory policy = new HtmlPolicyBuilder() .allowElements("p", "br", "strong", "em", "u", "a", "img", "ul", "ol", "li") .allowAttributes("href").onElements("a") .allowAttributes("src", "alt").onElements("img") .allowUrlProtocols("http", "https") .requireRelNofollowOnLinks() .toFactory(); String safeHtml = policy.sanitize(userInput);这段代码定义了一个白名单策略,只允许p、br、strong等标签,a标签只允许href属性,img标签只允许src和alt属性,并且只允许http和https协议的URL。requireRelNofollowOnLinks()会给所有链接加上rel="nofollow",防止SEO作弊。注意:白名单策略一定要根据业务需求定制。比如如果你的富文本需要支持表格,就要加上table、tr、td等标签。但千万不要为了省事直接允许所有标签,那就等于没做防护。5. 完整实操:Springboot项目XSS修复落地步骤5.1 第一步:引入依赖与工具选型我选择的是OWASP Java HTML Sanitizer作为白名单过滤工具,Jsoup作为辅助。在pom.xml中添加依赖:<dependency> <groupId>com.googlecode.owasp-java-html-sanitizer</groupId> <artifactId>owasp-java-html-sanitizer</artifactId> <version>20240325.1</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>选OWASP Java HTML Sanitizer的原因是它专门为XSS防护设计,白名单策略配置灵活,而且经过大量安全测试。Jsoup主要用来做一些简单的HTML清理和转义,比如把用户输入的纯文本转义成安全的HTML。版本选择上,建议用较新的版本,因为老版本可能存在已知的安全漏洞。但也要注意版本兼容性,比如有些老项目用的Springboot版本较低,可能需要选择兼容的Sanitizer版本。5.2 第二步:编写XSS过滤器核心代码过滤器的核心逻辑是拦截请求,对参数进行过滤。我写了一个XssFilter,继承OncePerRequestFilter:@Component public class XssFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String contentType = request.getContentType(); // 文件上传等请求不处理 if (contentType != null && contentType.contains("multipart/form-data")) { filterChain.doFilter(request, response); return; } XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(request); filterChain.doFilter(wrapper, response); } }这里用了一个包装类XssHttpServletRequestWrapper,继承HttpServletRequestWrapper,重写getParameter、getParameterValues、getHeader等方法,在返回前对内容做过滤:public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXss(value); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) return null; String[] cleaned = new String[values.length]; for (int i = 0; i < values.length; i++) { cleaned[i] = cleanXss(values[i]); } return cleaned; } @Override public String getHeader(String name) { String value = super.getHeader(name); return cleanXss(value); } private String cleanXss(String value) { if (value == null || value.isEmpty()) return value; // 使用Jsoup做基础转义 return Jsoup.clean(value, Safelist.basic()); } }Jsoup.clean配合Safelist.basic()会保留基本的HTML标签(如b、em、strong、a等),过滤掉危险标签和属性。对于普通文本参数,这个处理足够了。但这里有个问题:Jsoup.clean会把<转义成<,如果用户输入的是纯文本"1<2",输出会变成"1<2",在页面上显示是正常的,但如果后端需要用到原始值做计算或比较,就会出问题。所以对于不需要HTML的场景,我建议直接用转义而不是Jsoup清理。5.3 第三步:配置过滤器注册与URL排除规则Springboot中注册过滤器有两种方式:用@Component注解自动注册,或者用FilterRegistrationBean手动注册。我推荐用FilterRegistrationBean,因为可以灵活配置URL模式和排除规则:@Configuration public class XssFilterConfig { @Bean public FilterRegistrationBean<XssFilter> xssFilterRegistration() { FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new XssFilter()); registration.addUrlPatterns("/*"); registration.setName("xssFilter"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE); // 排除文件上传、静态资源等 registration.addInitParameter("exclusions", "/upload/*,/static/*,/api/file/*"); return registration; } }setOrder(Ordered.HIGHEST_PRECEDENCE)让过滤器最先执行,确保后续的过滤器或Controller拿到的都是过滤后的数据。排除规则里把文件上传、静态资源等不需要过滤的路径排除掉,避免影响正常功能。提示:过滤器的顺序很重要。如果有其他过滤器(比如编码过滤器、认证过滤器),XSS过滤器应该尽量靠前,但要在编码过滤器之后,否则可能出现乱码。5.4 第四步:富文本接口的白名单过滤实现对于富文本接口,全局过滤器可能会把正常的HTML标签也过滤掉,所以需要单独处理。我的做法是在Controller层对富文本字段做白名单过滤:@Service public class RichTextService { private static final PolicyFactory POLICY = new HtmlPolicyBuilder() .allowElements("p", "br", "strong", "em", "u", "a", "img", "ul", "ol", "li", "h1", "h2", "h3", "blockquote") .allowAttributes("href").onElements("a") .allowAttributes("src", "alt", "width", "height").onElements("img") .allowUrlProtocols("http", "https") .requireRelNofollowOnLinks() .toFactory(); public String sanitizeRichText(String input) { if (input == null || input.isEmpty()) return input; return POLICY.sanitize(input); } }然后在保存文章、评论等富文本内容时调用:@PostMapping("/article") public Result saveArticle(@RequestBody ArticleDTO article) { article.setContent(richTextService.sanitizeRichText(article.getContent())); articleService.save(article); return Result.success(); }这样既保留了富文本的格式,又过滤掉了危险标签和属性。5.5 第五步:前端渲染层的配合修改后端做了过滤,前端也不能掉以轻心。如果前端用了v-html或dangerouslySetInnerHTML,需要确保传入的内容已经过后端过滤。另外,前端在展示用户输入时,尽量用文本插值而不是HTML插值。Vue项目中,把v-html改成{{ }}是最安全的做法。如果必须用v-html(比如渲染富文本),确保内容来自可信的后端接口,并且后端已经做了白名单过滤。React项目中,避免使用dangerouslySetInnerHTML,如果必须用,同样要确保内容已经过滤。还有一个前端细节:URL参数的处理。如果前端从URL中获取参数并插入到页面中,需要做编码处理。比如用encodeURIComponent对参数编码,防止DOM型XSS。6. 常见问题与排查技巧实录6.1 过滤器不生效?检查这几个地方XSS过滤器不生效是常见问题,我遇到过几次,排查下来通常是这几个原因:过滤器顺序问题。如果XSS过滤器的order值比某个前置过滤器大,可能前置过滤器已经读取了请求参数,XSS过滤器再包装请求就无效了。解决方法是把XSS过滤器的order设为最高优先级。请求体未被过滤。HttpServletRequestWrapper默认只能过滤getParameter,对于@RequestBody接收的JSON数据,需要重写getInputStream和getReader方法,读取请求体内容并过滤后再返回。这个实现比较复杂,我一般建议对JSON接口在Controller层单独处理,或者用AOP切面统一处理。异步请求问题。Springboot的异步请求(@Async、Callable、DeferredResult)中,过滤器可能已经执行完毕,但请求参数在异步线程中才被读取,导致过滤失效。这种情况需要在异步线程中手动过滤。URL编码问题。如果请求参数是URL编码的,过滤器拿到的可能是编码后的内容,过滤规则可能匹配不到。需要先解码再过滤,或者调整过滤规则。6.2 过滤后数据变形?转义与原始值的取舍前面提到过,过滤后数据可能变形。比如用户输入"1<2",转义后变成"1<2",如果后端需要拿这个值做数值比较,就会出错。解决思路是区分场景:对于纯展示的文本,转义后存储或输出没问题。对于需要参与计算、比较、查询的字段,不要转义,而是在输出时转义。对于富文本,用白名单过滤而不是转义。我的做法是:数据库存储原始值,输出时根据上下文决定是否转义。Thymeleaf的th:text会自动转义,所以数据库存原始值即可。JSON接口返回原始值,前端渲染时用文本插值自动转义。只有富文本内容在入库前做白名单过滤。6.3 富文本过滤太严格?白名单策略调整经验白名单策略太严格会导致正常内容被过滤。比如用户输入了一个表格,但白名单里没有table标签,表格就被删掉了。或者用户插入了一个视频,但白名单里没有video标签,视频就没了。调整白名单策略的经验是:先收集业务场景中需要支持的HTML标签和属性,然后逐个添加到白名单。添加时要注意,只添加必要的属性,不要用通配符允许所有属性。比如img标签只需要src、alt、width、height,不要允许onerror、onload等事件属性。另外,style属性要特别小心。CSS中可以通过expression()执行JavaScript(老版本IE),或者通过url()加载外部资源。如果业务不需要用户自定义样式,建议直接禁止style属性。6.4 常见问题速查表问题现象可能原因排查方法解决方案过滤器不生效顺序不对/请求体未过滤打印过滤器日志调整order,重写getInputStream数据变形转义过度对比原始值和过滤后值区分场景,按需转义富文本格式丢失白名单太严格检查过滤后内容添加必要标签到白名单文件上传失败过滤器消费了请求体检查multipart请求排除文件上传路径性能下降过滤逻辑太重压测对比优化过滤规则,缓存策略6.5 几个我踩过的坑和独家建议第一个坑:过滤器里用了@Autowired注入的Bean,但过滤器是用new创建的。在FilterRegistrationBean中如果直接new XssFilter(),过滤器里的@Autowired不会生效。解决方法是把过滤器声明为@Component,然后在配置类中注入。第二个坑:Jsoup的Safelist.basic()会保留a标签的href属性,但不限制协议。攻击者可以用javascript:alert(1)作为href,点击链接时执行脚本。解决方法是自定义Safelist,限制协议为http和https。第三个坑:过滤规则把中文也过滤了。有些XSS过滤规则用正则匹配特殊字符,如果不小心把中文标点也匹配了,会导致中文内容被过滤。解决方法是测试时用中文、英文、特殊字符混合的内容验证。第四个坑:只做了后端过滤,忘了前端。后端过滤能防住大部分XSS,但如果前端有DOM型XSS,后端过滤无效。所以前端代码也要审查,特别是涉及innerHTML、v-html、dangerouslySetInnerHTML的地方。独家建议:建立XSS测试用例库。我整理了一套常见的XSS测试payload,每次上线前跑一遍,确保过滤规则有效。payload包括:<script>alert(1)</script> <img src=x onerror=alert(1)> <svg onload=alert(1)> <a href="javascript:alert(1)">click</a> <iframe src="javascript:alert(1)"></iframe> <body onload=alert(1)> <input onfocus=alert(1) autofocus> <details open ontoggle=alert(1)>把这些payload提交到各个输入接口,观察返回和渲染结果,能快速发现遗漏的XSS漏洞。7. 上线后的效果验证与持续防护7.1 如何验证XSS修复是否彻底修复完成后,验证是必不可少的环节。我一般从三个层面验证:手工测试。用上面的payload逐个测试输入接口,观察页面是否弹窗、是否有异常脚本执行。重点测试搜索、评论、昵称、个人简介等用户输入频繁的接口。自动化扫描。用OWASP ZAP或Burp Suite的扫描功能,对项目做一次全面的XSS扫描。这些工具能自动发现反射型和存储型XSS,但DOM型XSS需要人工分析。代码审计。用静态代码分析工具(如SonarQube)扫描代码,查找可能的XSS风险点。重点关注th:utext、<%= %>、innerHTML、v-html等关键字。验证通过的标准是:所有测试payload都被正确过滤或转义,页面没有异常脚本执行,功能正常不受影响。7.2 持续防护:日志监控与定期扫描XSS修复不是一劳永逸的,新功能上线可能引入新的漏洞。我建议做几件事:记录XSS过滤日志。在过滤器中记录被过滤的请求,包括IP、URL、参数、时间等。定期分析日志,发现异常攻击尝试。定期安全扫描。每月或每次大版本上线前,跑一次自动化扫描,及时发现新漏洞。代码审查清单。在代码审查时,把XSS防护作为检查项。特别是新增的输入接口和输出点,必须确认是否做了过滤或转义。安全培训。给团队成员做XSS防护培训,让每个人都了解XSS的原理和防护方法。很多XSS漏洞是因为开发者不知道风险而引入的。7.3 一个真实项目的修复前后对比最后分享一个我负责的项目的修复前后对比。修复前,项目存在存储型XSS漏洞,用户昵称可以包含恶意脚本,在用户列表页面自动执行。修复后,昵称字段在入库前做了HTML转义,输出时用th:text自动转义,漏洞消除。修复过程中,我做了以下改动:添加了XSS全局过滤器,覆盖所有非文件上传接口。对富文本内容使用OWASP Java HTML Sanitizer做白名单过滤。前端把v-html改成{{ }},富文本渲染组件确保内容已过滤。添加了XSS测试用例库,每次上线前跑一遍。配置了XSS过滤日志,监控异常请求。修复后,用之前的payload测试,全部被正确过滤。项目上线后运行了半年,没有再出现XSS相关问题。这个过程中,最大的体会是:XSS防护要趁早,不要等到出事了才补救。而且防护要全面,后端、前端、数据库、日志,每个环节都要考虑到。我个人在实际操作中的体会是,XSS修复最难的不是技术实现,而是坚持。新功能上线时,开发者往往关注功能实现,忽略了安全防护。所以建立一套自动化的防护机制和检查流程,比依赖人工审查更可靠。另外,安全是一个持续的过程,不是一次性的任务,定期回顾和更新防护策略,才能应对不断变化的攻击手法。
1. 从一个真实案例说起:为什么你的Springboot项目需要关注XSS去年帮一个朋友排查他负责的社区论坛项目,功能都正常,但某天运营反馈说用户列表页面会莫名其妙弹出一个登录框,输入任何账号密码都提示错误。一开始以为是前端组件出了问题,排查了半天前端代码没发现异常。后来抓包看接口返回数据,发现某个用户的昵称字段里藏了一段<script>标签,直接渲染到了页面上。这就是典型的存储型XSS——攻击者把恶意脚本通过用户输入接口存进了数据库,其他用户浏览页面时脚本自动执行。XSS(跨站脚本攻击)在Web安全领域常年位居OWASP Top 10前列,Springboot作为目前Java后端开发的主流框架,天然面临这类风险。很多人觉得Springboot已经帮我们做了很多安全防护,但XSS这件事,框架本身并不会自动帮你兜底。这篇文章我会从实际项目出发,把XSS的类型、Springboot项目中常见的漏洞入口、修复方案的选择逻辑、具体代码实现、以及踩过的坑,完整地梳理一遍。不管你是刚接触安全的新手,还是已经写过几年代码的老手,应该都能从中找到可以直接用的东西。2. XSS漏洞的本质与三种类型拆解2.1 浏览器如何被"骗"执行了不该执行的脚本要理解XSS,先得搞清楚浏览器是怎么处理HTML的。浏览器解析HTML时,遇到<script>标签就会执行里面的JavaScript代码,这是设计如此。问题在于,如果这段<script>不是开发者写的,而是用户输入的内容被原样输出到了页面上,浏览器分不清这是"开发者写的"还是"用户输入的",它只管执行。打个比方:你开了一家餐厅,菜单是固定的。但有个顾客偷偷把自己的菜名写在了菜单板上,其他顾客点了这道"菜",后厨就按照顾客写的奇怪配方去做,结果做出来的东西可能把厨房炸了。XSS就是这个逻辑——用户输入的内容被当成了页面代码的一部分。XSS的核心成因可以归结为一句话:不可信的数据未经处理就进入了HTML输出上下文。注意这里的关键词是"输出上下文",因为不同的上下文(HTML标签内、属性内、JavaScript代码内、CSS内、URL内)需要不同的处理方式。2.2 反射型、存储型、DOM型:三种XSS的区别与危害XSS通常分为三类,理解它们的区别对修复方案的选择至关重要。反射型XSS是最常见的一种。攻击者构造一个带有恶意参数的URL,诱导用户点击。用户点击后,恶意脚本作为响应内容"反射"回浏览器并执行。比如搜索功能,用户搜索的关键词会显示在结果页面上,如果没做处理,搜索<script>alert(1)</script>就会弹窗。反射型XSS的特点是恶意脚本不存储在服务器上,每次都需要用户主动点击恶意链接。存储型XSS危害最大。恶意脚本被存储到服务器(数据库、文件、缓存等),当其他用户访问包含这段数据的页面时自动执行。典型场景包括用户昵称、评论内容、留言板、个人简介等。存储型XSS不需要用户点击特定链接,只要访问正常页面就会中招,影响范围可能是所有访问该页面的用户。DOM型XSS比较特殊,恶意脚本的注入和触发完全发生在浏览器端的JavaScript中,不经过服务器。比如前端代码用location.hash获取URL片段并直接插入到页面中,攻击者构造#<img src=x onerror=alert(1)>就能触发。这类XSS的修复重点在前端JavaScript代码,后端防护措施对它无效。类型数据流向是否经过服务器危害程度典型场景反射型请求→响应→浏览器执行是中搜索、错误提示存储型请求→数据库→响应→浏览器执行是高评论、昵称、简介DOM型URL→前端JS→浏览器执行否中高前端路由、动态渲染2.3 为什么Springboot项目特别容易中招Springboot项目容易出XSS问题,主要有几个原因。第一,Springboot默认使用Thymeleaf或JSP作为模板引擎,模板引擎的th:utext(不转义文本)和th:text(转义文本)如果选错了,直接就是XSS漏洞。第二,现在前后端分离架构流行,后端返回JSON数据,前端用Vue/React渲染,很多人以为JSON接口不会有XSS问题,但如果前端用了v-html或dangerouslySetInnerHTML,照样中招。第三,Springboot的@RequestParam和@RequestBody接收参数时不会自动做XSS过滤,需要开发者自己处理。还有一个容易被忽视的点:Springboot项目经常集成富文本编辑器,比如UEditor、WangEditor、TinyMCE等。富文本编辑器允许用户输入HTML标签,如果后端不做白名单过滤,攻击者可以直接提交带有<script>或<img onerror>的内容,存储型XSS就来了。3. Springboot项目中XSS漏洞的常见入口排查3.1 接口层:哪些参数最容易成为攻击入口在Springboot项目中,XSS漏洞的入口主要集中在接收用户输入的地方。我梳理了一下常见的入口点:@RequestParam接收的查询参数:比如搜索关键词、分页参数、筛选条件等。这些参数经常被直接返回给前端展示。@PathVariable接收的路径变量:比如/user/{username},如果username包含恶意脚本,在某些场景下会被渲染。@RequestBody接收的JSON数据:用户提交的表单、评论、文章内容等。这是存储型XSS的主要来源。文件上传的文件名:上传文件时,文件名可能包含恶意脚本,如果前端展示文件名时没做处理,也会触发XSS。HTTP请求头:比如User-Agent、Referer等,如果后端把这些头信息记录并展示在管理后台,也可能成为XSS入口。排查的时候,我一般会从Controller层开始,把所有接收用户输入的方法列出来,逐个检查这些输入最终去了哪里。如果输入被直接返回给前端(比如return userInput;),或者被存储到数据库后又在页面上展示,那就是高风险点。3.2 模板渲染层:Thymeleaf和JSP的转义陷阱Thymeleaf是Springboot官方推荐的模板引擎,它默认会对th:text的内容进行HTML转义。比如<span th:text="${userInput}">,如果userInput是<script>alert(1)</script>,渲染出来会变成<script>alert(1)</script>,浏览器不会执行。但问题出在th:utext上。th:utext表示"unescaped text",不转义。有些开发者为了让富文本内容正常显示,会使用th:utext,这就打开了XSS的大门。我见过不少项目,文章详情页用th:utext渲染文章内容,但后端没有对文章内容做任何过滤,攻击者提交一篇带有恶意脚本的文章,所有阅读这篇文章的用户都会中招。JSP的情况类似,<%= %>默认不转义,<c:out>默认转义。很多老项目用JSP,开发者习惯用<%= %>直接输出,XSS漏洞一抓一大把。提示:Thymeleaf 3.x版本中,th:utext和th:text的区别一定要搞清楚。除非你非常确定内容已经过严格过滤,否则永远用th:text。3.3 前端渲染层:JSON接口返回后的二次渲染风险前后端分离项目中,后端返回JSON,前端负责渲染。很多人觉得JSON接口不会有XSS问题,因为JSON里的<script>标签不会被浏览器执行。这个想法只对了一半——JSON本身确实不会执行脚本,但前端拿到数据后怎么渲染才是关键。如果前端用Vue的{{ }}插值表达式,Vue会自动转义HTML,安全。但如果用了v-html指令,Vue不会转义,直接插入HTML,XSS就来了。React的dangerouslySetInnerHTML同理。还有一些前端框架的富文本渲染组件,默认就是插入HTML,如果后端没过滤,前端也没做处理,漏洞就存在。所以修复XSS不能只靠后端,前端渲染方式也要检查。但作为后端开发者,我们能做的是在数据出口处做好过滤和转义,尽量让前端拿到的是"干净"的数据。4. XSS修复方案选型:从过滤器到富文本白名单4.1 方案对比:转义、过滤、白名单,到底选哪个修复XSS的思路主要有三种:输出转义、输入过滤、白名单过滤。每种方案适用的场景不同,实际项目中往往是组合使用。输出转义是在数据输出到HTML页面时,把特殊字符转换成HTML实体。比如<转成<,>转成>,"转成"。这种方案的好处是不影响数据的存储,原始数据保持原样,只在展示时转义。缺点是需要在每个输出点都做处理,容易遗漏。Thymeleaf的th:text和JSP的<c:out>就是这种思路。输入过滤是在接收用户输入时,直接过滤掉或替换掉危险字符。比如把<script>直接删掉。这种方案的好处是数据入库时就是干净的,后续使用不需要再处理。缺点是可能误伤正常内容,比如用户真的想输入"1<2"这样的数学表达式,过滤后变成"12",数据就错了。白名单过滤是只允许特定的HTML标签和属性通过,其他一律过滤。这种方案适合富文本场景,比如允许<p>、<strong>、<a>等标签,但禁止<script>、<iframe>、on*事件属性等。白名单过滤比黑名单过滤安全得多,因为黑名单永远列不全所有危险标签和属性。方案原理优点缺点适用场景输出转义输出时转义特殊字符不影响原始数据需覆盖所有输出点普通文本展示输入过滤接收时过滤危险字符数据入库即干净可能误伤正常内容简单文本输入白名单过滤只允许安全标签通过安全性高配置复杂富文本内容4.2 为什么我最终选择了"过滤器+白名单"的组合方案在实际项目中,我最终采用的方案是:全局XSS过滤器 + 富文本白名单过滤。具体来说,对于普通接口参数,通过过滤器统一做HTML转义或危险字符过滤;对于富文本内容,使用白名单库(比如OWASP Java HTML Sanitizer或Jsoup)做标签白名单过滤。选择这个组合方案的理由有几个。第一,全局过滤器可以覆盖大部分接口,减少遗漏。第二,白名单过滤可以精确控制富文本内容,既保证安全又不影响正常功能。第三,Springboot的Filter机制很容易集成,不需要改动太多现有代码。全局过滤器的实现思路是:拦截所有请求,对请求参数、请求体、请求头进行XSS过滤,把处理后的请求传递给后续的Controller。这样Controller拿到的就是已经过滤过的数据,不需要每个方法都手动处理。但全局过滤器也有坑。比如文件上传接口,请求体是二进制流,过滤器如果去读取请求体,会导致流被消费掉,后续Controller读不到数据。所以过滤器需要排除文件上传等特殊接口。另外,过滤器的过滤规则如果太严格,可能会把正常内容也过滤掉,需要根据业务场景调整。4.3 富文本场景的特殊处理:既要安全又要保留格式富文本是XSS修复中最棘手的场景。用户需要输入加粗、斜体、链接、图片等格式,这些都需要HTML标签来实现。如果全部转义,富文本就变成了纯文本,功能就废了。如果全部放行,XSS漏洞就来了。我的做法是使用OWASP Java HTML Sanitizer,定义一个白名单策略,只允许安全的标签和属性通过。比如:PolicyFactory policy = new HtmlPolicyBuilder() .allowElements("p", "br", "strong", "em", "u", "a", "img", "ul", "ol", "li") .allowAttributes("href").onElements("a") .allowAttributes("src", "alt").onElements("img") .allowUrlProtocols("http", "https") .requireRelNofollowOnLinks() .toFactory(); String safeHtml = policy.sanitize(userInput);这段代码定义了一个白名单策略,只允许p、br、strong等标签,a标签只允许href属性,img标签只允许src和alt属性,并且只允许http和https协议的URL。requireRelNofollowOnLinks()会给所有链接加上rel="nofollow",防止SEO作弊。注意:白名单策略一定要根据业务需求定制。比如如果你的富文本需要支持表格,就要加上table、tr、td等标签。但千万不要为了省事直接允许所有标签,那就等于没做防护。5. 完整实操:Springboot项目XSS修复落地步骤5.1 第一步:引入依赖与工具选型我选择的是OWASP Java HTML Sanitizer作为白名单过滤工具,Jsoup作为辅助。在pom.xml中添加依赖:<dependency> <groupId>com.googlecode.owasp-java-html-sanitizer</groupId> <artifactId>owasp-java-html-sanitizer</artifactId> <version>20240325.1</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>选OWASP Java HTML Sanitizer的原因是它专门为XSS防护设计,白名单策略配置灵活,而且经过大量安全测试。Jsoup主要用来做一些简单的HTML清理和转义,比如把用户输入的纯文本转义成安全的HTML。版本选择上,建议用较新的版本,因为老版本可能存在已知的安全漏洞。但也要注意版本兼容性,比如有些老项目用的Springboot版本较低,可能需要选择兼容的Sanitizer版本。5.2 第二步:编写XSS过滤器核心代码过滤器的核心逻辑是拦截请求,对参数进行过滤。我写了一个XssFilter,继承OncePerRequestFilter:@Component public class XssFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String contentType = request.getContentType(); // 文件上传等请求不处理 if (contentType != null && contentType.contains("multipart/form-data")) { filterChain.doFilter(request, response); return; } XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(request); filterChain.doFilter(wrapper, response); } }这里用了一个包装类XssHttpServletRequestWrapper,继承HttpServletRequestWrapper,重写getParameter、getParameterValues、getHeader等方法,在返回前对内容做过滤:public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXss(value); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) return null; String[] cleaned = new String[values.length]; for (int i = 0; i < values.length; i++) { cleaned[i] = cleanXss(values[i]); } return cleaned; } @Override public String getHeader(String name) { String value = super.getHeader(name); return cleanXss(value); } private String cleanXss(String value) { if (value == null || value.isEmpty()) return value; // 使用Jsoup做基础转义 return Jsoup.clean(value, Safelist.basic()); } }Jsoup.clean配合Safelist.basic()会保留基本的HTML标签(如b、em、strong、a等),过滤掉危险标签和属性。对于普通文本参数,这个处理足够了。但这里有个问题:Jsoup.clean会把<转义成<,如果用户输入的是纯文本"1<2",输出会变成"1<2",在页面上显示是正常的,但如果后端需要用到原始值做计算或比较,就会出问题。所以对于不需要HTML的场景,我建议直接用转义而不是Jsoup清理。5.3 第三步:配置过滤器注册与URL排除规则Springboot中注册过滤器有两种方式:用@Component注解自动注册,或者用FilterRegistrationBean手动注册。我推荐用FilterRegistrationBean,因为可以灵活配置URL模式和排除规则:@Configuration public class XssFilterConfig { @Bean public FilterRegistrationBean<XssFilter> xssFilterRegistration() { FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new XssFilter()); registration.addUrlPatterns("/*"); registration.setName("xssFilter"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE); // 排除文件上传、静态资源等 registration.addInitParameter("exclusions", "/upload/*,/static/*,/api/file/*"); return registration; } }setOrder(Ordered.HIGHEST_PRECEDENCE)让过滤器最先执行,确保后续的过滤器或Controller拿到的都是过滤后的数据。排除规则里把文件上传、静态资源等不需要过滤的路径排除掉,避免影响正常功能。提示:过滤器的顺序很重要。如果有其他过滤器(比如编码过滤器、认证过滤器),XSS过滤器应该尽量靠前,但要在编码过滤器之后,否则可能出现乱码。5.4 第四步:富文本接口的白名单过滤实现对于富文本接口,全局过滤器可能会把正常的HTML标签也过滤掉,所以需要单独处理。我的做法是在Controller层对富文本字段做白名单过滤:@Service public class RichTextService { private static final PolicyFactory POLICY = new HtmlPolicyBuilder() .allowElements("p", "br", "strong", "em", "u", "a", "img", "ul", "ol", "li", "h1", "h2", "h3", "blockquote") .allowAttributes("href").onElements("a") .allowAttributes("src", "alt", "width", "height").onElements("img") .allowUrlProtocols("http", "https") .requireRelNofollowOnLinks() .toFactory(); public String sanitizeRichText(String input) { if (input == null || input.isEmpty()) return input; return POLICY.sanitize(input); } }然后在保存文章、评论等富文本内容时调用:@PostMapping("/article") public Result saveArticle(@RequestBody ArticleDTO article) { article.setContent(richTextService.sanitizeRichText(article.getContent())); articleService.save(article); return Result.success(); }这样既保留了富文本的格式,又过滤掉了危险标签和属性。5.5 第五步:前端渲染层的配合修改后端做了过滤,前端也不能掉以轻心。如果前端用了v-html或dangerouslySetInnerHTML,需要确保传入的内容已经过后端过滤。另外,前端在展示用户输入时,尽量用文本插值而不是HTML插值。Vue项目中,把v-html改成{{ }}是最安全的做法。如果必须用v-html(比如渲染富文本),确保内容来自可信的后端接口,并且后端已经做了白名单过滤。React项目中,避免使用dangerouslySetInnerHTML,如果必须用,同样要确保内容已经过滤。还有一个前端细节:URL参数的处理。如果前端从URL中获取参数并插入到页面中,需要做编码处理。比如用encodeURIComponent对参数编码,防止DOM型XSS。6. 常见问题与排查技巧实录6.1 过滤器不生效?检查这几个地方XSS过滤器不生效是常见问题,我遇到过几次,排查下来通常是这几个原因:过滤器顺序问题。如果XSS过滤器的order值比某个前置过滤器大,可能前置过滤器已经读取了请求参数,XSS过滤器再包装请求就无效了。解决方法是把XSS过滤器的order设为最高优先级。请求体未被过滤。HttpServletRequestWrapper默认只能过滤getParameter,对于@RequestBody接收的JSON数据,需要重写getInputStream和getReader方法,读取请求体内容并过滤后再返回。这个实现比较复杂,我一般建议对JSON接口在Controller层单独处理,或者用AOP切面统一处理。异步请求问题。Springboot的异步请求(@Async、Callable、DeferredResult)中,过滤器可能已经执行完毕,但请求参数在异步线程中才被读取,导致过滤失效。这种情况需要在异步线程中手动过滤。URL编码问题。如果请求参数是URL编码的,过滤器拿到的可能是编码后的内容,过滤规则可能匹配不到。需要先解码再过滤,或者调整过滤规则。6.2 过滤后数据变形?转义与原始值的取舍前面提到过,过滤后数据可能变形。比如用户输入"1<2",转义后变成"1<2",如果后端需要拿这个值做数值比较,就会出错。解决思路是区分场景:对于纯展示的文本,转义后存储或输出没问题。对于需要参与计算、比较、查询的字段,不要转义,而是在输出时转义。对于富文本,用白名单过滤而不是转义。我的做法是:数据库存储原始值,输出时根据上下文决定是否转义。Thymeleaf的th:text会自动转义,所以数据库存原始值即可。JSON接口返回原始值,前端渲染时用文本插值自动转义。只有富文本内容在入库前做白名单过滤。6.3 富文本过滤太严格?白名单策略调整经验白名单策略太严格会导致正常内容被过滤。比如用户输入了一个表格,但白名单里没有table标签,表格就被删掉了。或者用户插入了一个视频,但白名单里没有video标签,视频就没了。调整白名单策略的经验是:先收集业务场景中需要支持的HTML标签和属性,然后逐个添加到白名单。添加时要注意,只添加必要的属性,不要用通配符允许所有属性。比如img标签只需要src、alt、width、height,不要允许onerror、onload等事件属性。另外,style属性要特别小心。CSS中可以通过expression()执行JavaScript(老版本IE),或者通过url()加载外部资源。如果业务不需要用户自定义样式,建议直接禁止style属性。6.4 常见问题速查表问题现象可能原因排查方法解决方案过滤器不生效顺序不对/请求体未过滤打印过滤器日志调整order,重写getInputStream数据变形转义过度对比原始值和过滤后值区分场景,按需转义富文本格式丢失白名单太严格检查过滤后内容添加必要标签到白名单文件上传失败过滤器消费了请求体检查multipart请求排除文件上传路径性能下降过滤逻辑太重压测对比优化过滤规则,缓存策略6.5 几个我踩过的坑和独家建议第一个坑:过滤器里用了@Autowired注入的Bean,但过滤器是用new创建的。在FilterRegistrationBean中如果直接new XssFilter(),过滤器里的@Autowired不会生效。解决方法是把过滤器声明为@Component,然后在配置类中注入。第二个坑:Jsoup的Safelist.basic()会保留a标签的href属性,但不限制协议。攻击者可以用javascript:alert(1)作为href,点击链接时执行脚本。解决方法是自定义Safelist,限制协议为http和https。第三个坑:过滤规则把中文也过滤了。有些XSS过滤规则用正则匹配特殊字符,如果不小心把中文标点也匹配了,会导致中文内容被过滤。解决方法是测试时用中文、英文、特殊字符混合的内容验证。第四个坑:只做了后端过滤,忘了前端。后端过滤能防住大部分XSS,但如果前端有DOM型XSS,后端过滤无效。所以前端代码也要审查,特别是涉及innerHTML、v-html、dangerouslySetInnerHTML的地方。独家建议:建立XSS测试用例库。我整理了一套常见的XSS测试payload,每次上线前跑一遍,确保过滤规则有效。payload包括:<script>alert(1)</script> <img src=x onerror=alert(1)> <svg onload=alert(1)> <a href="javascript:alert(1)">click</a> <iframe src="javascript:alert(1)"></iframe> <body onload=alert(1)> <input onfocus=alert(1) autofocus> <details open ontoggle=alert(1)>把这些payload提交到各个输入接口,观察返回和渲染结果,能快速发现遗漏的XSS漏洞。7. 上线后的效果验证与持续防护7.1 如何验证XSS修复是否彻底修复完成后,验证是必不可少的环节。我一般从三个层面验证:手工测试。用上面的payload逐个测试输入接口,观察页面是否弹窗、是否有异常脚本执行。重点测试搜索、评论、昵称、个人简介等用户输入频繁的接口。自动化扫描。用OWASP ZAP或Burp Suite的扫描功能,对项目做一次全面的XSS扫描。这些工具能自动发现反射型和存储型XSS,但DOM型XSS需要人工分析。代码审计。用静态代码分析工具(如SonarQube)扫描代码,查找可能的XSS风险点。重点关注th:utext、<%= %>、innerHTML、v-html等关键字。验证通过的标准是:所有测试payload都被正确过滤或转义,页面没有异常脚本执行,功能正常不受影响。7.2 持续防护:日志监控与定期扫描XSS修复不是一劳永逸的,新功能上线可能引入新的漏洞。我建议做几件事:记录XSS过滤日志。在过滤器中记录被过滤的请求,包括IP、URL、参数、时间等。定期分析日志,发现异常攻击尝试。定期安全扫描。每月或每次大版本上线前,跑一次自动化扫描,及时发现新漏洞。代码审查清单。在代码审查时,把XSS防护作为检查项。特别是新增的输入接口和输出点,必须确认是否做了过滤或转义。安全培训。给团队成员做XSS防护培训,让每个人都了解XSS的原理和防护方法。很多XSS漏洞是因为开发者不知道风险而引入的。7.3 一个真实项目的修复前后对比最后分享一个我负责的项目的修复前后对比。修复前,项目存在存储型XSS漏洞,用户昵称可以包含恶意脚本,在用户列表页面自动执行。修复后,昵称字段在入库前做了HTML转义,输出时用th:text自动转义,漏洞消除。修复过程中,我做了以下改动:添加了XSS全局过滤器,覆盖所有非文件上传接口。对富文本内容使用OWASP Java HTML Sanitizer做白名单过滤。前端把v-html改成{{ }},富文本渲染组件确保内容已过滤。添加了XSS测试用例库,每次上线前跑一遍。配置了XSS过滤日志,监控异常请求。修复后,用之前的payload测试,全部被正确过滤。项目上线后运行了半年,没有再出现XSS相关问题。这个过程中,最大的体会是:XSS防护要趁早,不要等到出事了才补救。而且防护要全面,后端、前端、数据库、日志,每个环节都要考虑到。我个人在实际操作中的体会是,XSS修复最难的不是技术实现,而是坚持。新功能上线时,开发者往往关注功能实现,忽略了安全防护。所以建立一套自动化的防护机制和检查流程,比依赖人工审查更可靠。另外,安全是一个持续的过程,不是一次性的任务,定期回顾和更新防护策略,才能应对不断变化的攻击手法。