1. 为什么 Java 项目里 XSS 仍然高频:三个被低估的真相
Java 开发常见安全问题里,跨站脚本(XSS)是我做代码评审和面试候选人时最容易遇到的。按理说 Java 生态起步早、框架成熟,很多人默认“框架已经帮我挡了 XSS”,可真到了线上项目里,XSS 依旧是高危漏洞排行榜的常客。我见过不少团队,安全整改做了一轮又一轮,结果换个入口、换个输出位置,同样的问题又冒出来。这一篇就把我这些年踩过的坑、排查过的链路、以及最终落地的修复方式完整讲一遍,主要面向 Java 后端开发者,也包括需要跟后端对齐的前端同学。
先说结论:XSS 的根因不是“用户输入了坏东西”,而是“程序把不可信数据放到了会被浏览器当成代码执行的位置”。所以只做输入过滤、只靠框架默认转义、只在前端拦截,都不够。下面展开讲三个最容易被低估的真相。
1.1 “框架默认转义”不等于“全链路安全”
现在 Java Web 项目基本分为两种形态:一种是后端渲染模板,比如 JSP、Thymeleaf、FreeMarker;另一种是前后端分离,后端只出 JSON,前端用 Vue、React 这类框架渲染。第一种形态下,现代模板引擎默认确实会做 HTML 转义,比如 Thymeleaf 的th:text默认把<变成<。但问题往往出在“例外用法”上——有人为了让富文本生效直接用了th:utext,也有人把后端拼好的 HTML 字符串整体塞进页面,还有人为了省事写<%= request.getParameter("q") %>这种 JSP 老写法,等于亲手把转义机制绕过了。
第二种形态更容易出问题。后端返回 JSON 时,如果只是把字符串原样丢给前端,前端渲染时再来一句element.innerHTML = data.content,那 XSS 就只取决于前端怎么写,跟后端用什么框架无关了。换句话说,XSS 是整条链路的漏洞,不是“后端做了输出编码”或者“前端用了框架”就能单独解决的。Java 后端能控制的是服务端所有出口,但必须知道前端会把数据用到哪个上下文里,才能决定编码策略。这一点不理解,后面所有修复都是打地鼠。
1.2 输入过滤只是在给攻击者出题
我早年也犯过这个错:写了一个全局过滤器,把script、javascript:、onerror这些关键词正则替换掉,以为万事大吉。实际上攻击者绕过黑名单的手段非常多:全小写不行就混大小写,<script>不行就用<scr<script>ipt>,等浏览器解析完又变回<script>;不能直接写javascript:就写java%0asc ript:,或者用 HTML 实体编码;标签被拦就换成<img src=x onerror=alert(1)>这种不需要script关键字的 Payload。黑名单永远在跟攻击者的创造力赛跑,而且几乎总是输。
更准确地说,输入校验应该用来做“业务规则”而非“安全边界”。比如手机号、邮箱、昵称这种有明确格式约束的字段,用白名单正则校验是合理的,因为业务上就不允许特殊字符。但像留言内容、搜索关键词、个人简介这类天然需要包含各种字符的字段,你没法用白名单限制,就只能靠输出编码兜底。如果把过滤当作主要防线,攻击者只需要多试几次编码和变体,就能绕过。
1.3 XSS 的实际危害远超“弹个窗”
不少开发者觉得 XSS 不就是弹个 alert 嘛,危害不大。实际上 XSS 是很多严重攻击的“垫脚石”:攻击者拿到执行权限后,可以用document.cookie窃取会话凭证,即使 Cookie 带 HttpOnly,也能通过页面篡改诱导用户输入账号密码;可以在已登录用户身份下发帖、转账、改配置;可以伪造页面内容做钓鱼;甚至可以借助浏览器发起内网探测和横向请求。对 Java 后端来说,XSS 表面上是前端漏洞,但根因经常是后端输出时没有做上下文感知编码,或者接口设计时没考虑数据会被渲染到哪个位置。
理解到这一层,后面的修复方案才有意义。下面我拿一个非常典型的反射型 XSS 场景,带你看完整攻击链路。
2. 反射型 XSS 的完整触发链路:从问题代码到浏览器弹窗
反射型 XSS 是最容易讲清楚、也最容易在代码评审里抓到的一类。它的特点是恶意 Payload 放在 URL 参数里,服务端没做任何处理直接回显到页面,浏览器解析后执行。整个过程不涉及数据库,所以叫“反射”。
2.1 一段最典型的“坏代码”
我在不少模拟项目里都见过类似的写法,比如一个简单的搜索接口,后端为了“用户体验”,直接把用户搜索的关键词拼进返回的 HTML 里:
@GetMapping(value = "/search", produces = "text/html") public void search(@RequestParam String q, HttpServletResponse response) throws IOException { response.setContentType("text/html;charset=UTF-8"); response.getWriter().println("<div>您搜索的是:" + q + "</div>"); }这段代码的问题一目了然:q是用户可控参数,服务端用字符串拼接的方式把它嵌进了 HTML 结构。如果请求是:
/search?q=<script>alert(document.cookie)</script>服务端返回的内容就会变成:
<div>您搜索的是:<script>alert(document.cookie)</script></div>浏览器拿到响应后,会把<script>当成真正的脚本标签加载执行,alert(document.cookie)就会弹出当前站点的 Cookie。攻击者把这条 URL 缩短后发给受害者,受害者点击即中招。
2.2 一行恶意 URL 是如何被执行的
整个触发过程可以分为四步:攻击者构造恶意链接,诱导用户点击;浏览器向 Java 服务端发起带参数的 GET 请求;服务端未编码地拼接参数并返回 HTML;浏览器把响应按 HTML 解析,遇到<script>或事件属性时执行 JavaScript。
这里的关键是“浏览器按什么上下文解析”。同样是用户输入,放到 HTML 元素内容里、放到 HTML 属性里、放到<script>标签里、放到 URL 里,需要的编码规则完全不同。我整理了一张常用对照表,代码评审时可以直接对着查:
| 输出位置 | 需要转义的关键字符 | Payload 示例 |
|---|---|---|
| HTML 元素内容 | <>& | <script>alert(1)</script> |
| HTML 属性值 | "'空格 | "><img src=x onerror=alert(1)> |
| JavaScript 代码 | '"换行\ | '; alert(1);// |
| URL 地址 | javascript:空格 | javascript:alert(1) |
| CSS 上下文 | expressionurl() | url(javascript:alert(1)) |
很多团队只对<和>做了转义,结果属性值里的"没处理,攻击者用"><img src=x onerror=...>就能闭合标签注入新属性。类似这种细节,就是“做了防护但没做对”的典型。
2.3 为什么反射型 XSS 经常绕过 WAF 和过滤器
加了 Web 应用防火墙或者全局过滤器的项目,反射型 XSS 不是就绝迹了,而是被“逼迫”出更隐蔽的变体。比如过滤器拦截了<script>,攻击者可以改用<svg/onload=alert(1)>;拦截了alert,就改用confirm、prompt或者alert的编码形式;拦截了onerror,就尝试onmouseover、onfocus,配合自动聚焦属性触发。
还有一类绕过是“双重编码”。如果服务端对参数先做了一次 HTML 转义,但由于拼接逻辑复杂,后续又通过某个环节把实体解码回去,攻击者预编码一下就能过关。所以不要迷信“有个过滤器就安全了”,最好的状态是:入口不做黑名单,出口按上下文编码,让数据无论怎么进来,到了浏览器手里都只是普通文本。
3. 修复组合拳:输出编码、输入校验与安全响应头如何分工
真正靠谱的 XSS 修复方案,不是靠某一种技术包打天下,而是让几层措施各干各的活。我现在的习惯是:输出编码兜底,输入校验做业务约束,响应头和 Cookie 属性做纵深防御。三层缺一不可。
3.1 输出编码是底线,但要按“上下文”来编
输出编码的核心理念是“不可信数据进入任何上下文时,都必须转成该上下文中无意义的纯字符”。在 HTML 元素内容里,<要变成<;在 HTML 属性里,"要变成";在 JavaScript 字符串里,单引号要变成\x27,换行要变成\n。一套转义函数打天下,是很多项目仍然被绕过的原因。
如果你不想引入额外的依赖,自己写一个 HTML 上下文的基础转义工具类也很简单,我在项目里常用的版本是这样:
public final class HtmlEscape { private HtmlEscape() { } public static String escape(String input) { if (input == null) { return ""; } StringBuilder sb = new StringBuilder(input.length()); for (char c : input.toCharArray()) { switch (c) { case '&' -> sb.append("&"); case '<' -> sb.append("<"); case '>' -> sb.append(">"); case '"' -> sb.append("""); case '\'' -> sb.append("'"); case '/' -> sb.append("/"); default -> sb.append(c); } } return sb.toString(); } }注意我把单引号和斜杠也转义了。单引号是属性值最常见的闭合符,斜杠是闭合标签和触发自闭合标签的关键字符,宁可多转也不要漏转。实际工程里我更推荐直接用专门做输出编码的开源工具库,因为那些库会区分 HTML、HTML 属性、JavaScript、CSS、URL 共五种上下文,按需调用对应方法,比手写函数覆盖得更全面。总之记住一句话:不是“要不要转义”,而是“在哪个上下文里按什么规则转义”。
3.2 输入校验做减法,业务规则做白名单
输入校验的选择标准很简单:业务上允许什么,就只允许什么。比如用户名,通常只允许中文、英文、数字和下划线,那就用白名单正则挡掉其他字符:
public boolean isValidUsername(String username) { if (username == null || username.length() < 2 || username.length() > 20) { return false; } return username.matches("^[\\w\\u4e00-\\u9fa5]{2,20}$"); }这种校验不是为了防 XSS,而是让用户输入符合产品预期。对于昵称这种字段,白名单一收紧,绝大多数注入型 Payload 根本进不了系统。但必须明确:白名单校验代替不了输出编码。因为数据库里可能还有历史脏数据,第三方系统也可能把数据传到你的接口里,你不能假设所有数据都经过过校验。
3.3 配套响应头与 Cookie 属性
响应头是最后一道纵深防御。即使某一处漏了转义,合理的响应头也能把损失降到最低。我在所有 Java Web 项目里都会至少配置这几项:
| 响应头 | 推荐值 | 作用 |
|---|---|---|
Content-Type | text/html; charset=UTF-8 | 避免浏览器错误猜测内容类型 |
X-Content-Type-Options | nosniff | 禁止 MIME 嗅探,降低上传文件被当作 HTML 执行的风险 |
Content-Security-Policy | default-src 'self' | 限制脚本来源,即使注入脚本也不执行 |
Set-Cookie | HttpOnly; Secure; SameSite=Lax | 降低会话被 XSS 窃取的风险 |
X-Frame-Options | SAMEORIGIN | 防止点击劫持,和 XSS 配合使用更稳妥 |
在 Spring Boot 项目里,可以用一个过滤器统一添加这些头,而不用在每个 Controller 里重复写:
@Component public class SecurityHeaderFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setHeader("X-Content-Type-Options", "nosniff"); httpResponse.setHeader("Content-Security-Policy", "default-src 'self'"); httpResponse.setHeader("X-Frame-Options", "SAMEORIGIN"); httpResponse.setHeader("Set-Cookie", "HttpOnly; Secure; SameSite=Lax"); chain.doFilter(request, response); } }HttpOnly 的意义在于:即使攻击者注入了脚本,也读不到document.cookie,会话凭证没那么容易被窃取。但注意,HttpOnly 不是 XSS 的“解药”,它只是缓解了“窃取会话”这一种危害。攻击者仍然可以以用户身份在页面上操作、篡改内容、诱导输入,所以前面两层依然要做。
4. 存储型 XSS 排查实录:一次后台管理项目的完整整改
反射型 XSS 讲完了,更麻烦的是存储型 XSS。因为它把恶意内容写进了数据库,只要有人打开相关页面就会触发,污染范围和数据持久性都远超反射型。下面这段排查过程来自一个我参与整改的后台管理类项目,里面所有信息都做了脱敏,但排查链路和修复方法非常还原。
4.1 复现现场:留言列表一打开就弹窗
当时测试同学反馈说:在“用户留言管理”页面,正常打开列表就会弹出一个内容为document.cookie的 alert。我们第一反应是前端出了问题,但打开浏览器开发者工具看了网络请求,发现后端返回的 JSON 里,用户昵称字段的值就是一段完整的<svg/onload=alert(document.cookie)>。前端拿到数据后用了innerHTML渲染头像和昵称,SVG 标签一进入 DOM 就触发了 onload 事件。
也就是说,根因不在前端这次渲染,而在更早的环节:某个用户提交留言时,昵称字段没有做任何校验和编码,直接入库;管理后台读取后原样展示,于是每次打开列表都被执行。
4.2 根因定位:数据库里的脏数据经过层层拼接渲染
我们把问题拆开,发现有两个漏洞点叠加。第一,留言提交接口对昵称字段没有业务校验,只做了“非空判断”,攻击者可以在注册或个人信息里填入<svg/onload=...>这类内容并保存成功。第二,后台列表接口把数据库里的昵称原样返给前端,前端为了显示效果又用了不安全的 DOM 操作方式。这才是存储型 XSS 最常见的形态:后端不管、前端信任、数据库变成“毒源”。
定位时我们还发现,不只是昵称,用户的个人签名、留言内容、商品标题、公告内容等文本字段全部没有统一处理。原因也很现实:开发时大家都在赶功能,没有约定“用户可控数据进入数据库之前要做什么、出数据库之后要做什么”,各自为战,自然漏得千疮百孔。
4.3 修复措施:编码、清洗、复查
整改分三步。第一步,前端把所有innerHTML渲染改成文本渲染;必须展示富文本的地方,用白名单过滤组件处理后再插入。第二步,后端在输出接口层做 HTML 编码,确保即使数据库里有脏数据,普通文本接口也不会把它当成 HTML 标签输出。第三步,写了一个数据清洗脚本,遍历所有业务表里的文本字段,把符合危险特征的历史数据批量转义,并保留转义前备份以便审计。
这里有个容易忽略的点:清洗数据库时不能直接把<全部替换成<,否则用户原本就想输入“a < b”这种正常内容,会被二次转义后显示成乱码。正确做法是先识别出可执行上下文才需要处理,再对可疑内容做定长替换或整体转义。更稳妥的方案是让清洗脚本与线上显示规则保持一致,尽量做到“显示层怎么编码,清洗时就怎么编码”。
4.4 同样的坑还藏在哪些接口
修完留言模块后,我们又按同样的思路把整个系统扫了一遍,结果又挖出好几个同类问题:搜索结果页把关键词直接拼进 HTML;订单备注在导出 Excel 时被公式注入;日志系统把请求参数原样打到管理页;文件上传接口允许上传.html和.svg文件,访问时直接被浏览器渲染。
这些场景都有一个共性:开发时只考虑了“数据怎么存怎么取”,没考虑“数据最终会被浏览器或办公软件以什么方式解释”。所以整改存储型 XSS,不能只盯着用户输入那一环,要把所有“数据出口”都列出来逐一遍历。
5. 富文本、导出文件与老系统:Java 侧最容易被绕过的三个角落
如果说上面讲的是“常规 XSS”,那下面这几个场景就是“变种 XSS”,也是很多老练的开发者在评审时最容易放过的位置。
5.1 富文本场景:转义与业务格式的两难
富文本编辑器的存在就是为了让用户输入带格式的内容,所以你不能简单地把<b>、<a>这些标签转义成文本,否则产品功能就废了。但也不能直接信任用户写的 HTML,因为<b>旁边的<script>同样会被执行。
正确的做法是“白名单过滤”:允许一小部分安全的标签和属性,其余全部剔除。比如标签白名单只放p、br、strong、em、ul、ol、li、a、img等,属性白名单只放href、src、alt、title,并且对href、src的值做协议校验,只允许http、https、mailto,拒绝javascript:。
这个需求看起来简单,但自己用正则实现极容易踩坑,因为 HTML 的解析规则非常复杂。我更推荐找一个长期维护的开源富文本过滤库,而不是自己造轮子。配置时注意三点:一是事件属性一律不进白名单,也就是on*全部移除;二是style属性如果产品不需要,直接全部移除,需要的话也要用 CSS 属性白名单,防止expression这类老 IE 专用的危险写法;三是<iframe>默认不放行,绝大多数业务场景都用不到。
5.2 文件上传与导出:SVG/HTML 也是 XSS 载体
很多 Java 项目对文件上传只校验了后缀名,认为只要不允许上传.exe就安全。实际上攻击者可以上传一个.svg文件,里面写上<script>或者onload事件,只要这个文件被浏览器直接访问,脚本就会在站点域下执行。.html文件更直接,上传后拿到 URL 就是一条完整的存储型 XSS。
应对思路分两层:第一层,上传时做类型校验,不仅看扩展名,还要探测文件内容格式;第二层,关键的是响应头。如果文件必须由后端读出来展示,那就给文件响应加Content-Disposition: attachment,让浏览器只下载不渲染;如果产品要求预览图片,可以用单独的图片域名托管,并且对该域名设置Content-Security-Policy: sandbox或关闭脚本执行。
导出功能同样不能忽视。把用户可控内容写进 CSV 再让用户下载,如果内容以=+-@开头,Excel 打开时可能把它当作公式执行,这就是 CSV 注入。虽然它不属于经典 XSS,但危害路径很相似。导出前对这些字符做一下处理,或者干脆在导出内容前面加一个不可见的单引号,能让办公软件把它当纯文本。
5.3 老系统与 JSP 遗留:统一从出口收口
接手过老 Java Web 项目的人都知道,最头疼的不是业务复杂度,而是大量 JSP 页面里直接写<%= request.getParameter("xxx") %>或者<%= user.getName() %>。JSP 时代没有现代模板引擎的自动转义,所有输出全靠开发者自觉。
这种系统要一页一页改成本太高,我的建议是在出口层收口:先排查所有直接拼接输出的 Servlet 和 JSP,把能切换到 JSTL 的c:out的地方都切换过去;不方便切换的,至少做一个全局输出编码过滤器,对响应流做统一处理。这个方案虽然不如上下文编码精细,但能在短时间内把最危险的一批反射型 XSS 堵住。随后再逐步把老 JSP 替换成新模板引擎,让“默认转义”成为系统默认行为。
另外,老系统容易忽略一个点:响应里没有显式指定Content-Type的charset。有些浏览器遇到没有字符集声明的响应,可能会启用“编码推测”,历史上就出现过利用 UTF-7 编码绕过过滤器的攻击方式。解决办法很简单,容器或 Filter 里统一给响应设置Content-Type: text/html; charset=UTF-8,让浏览器按固定编码解析。
6. 验证方法与流程落地:把 XSS 问题挡在发布之前
讲完修复,还得讲验证和流程。没有验证,你根本不知道改动有没有生效;没有流程,下一次版本迭代又会把漏洞带回来。
6.1 手把手自测:用一组 Payload 过一遍接口
我给团队做过一个很简单的自测清单,让开发者在提测前自己跑一遍。方法是在所有接收用户输入的接口上,分别尝试下面这几类 Payload,然后观察响应体和页面行为:
- HTML 注入型:
<script>alert(1)</script>,验证是否被转义。 - 属性闭合型:
"><img src=x onerror=alert(1)>,验证属性值是否安全。 - 事件触发型:
<svg/onload=alert(1)>,验证无需script标签的注入。 - 编码变体型:
<script>alert(1)</script>,验证实体编码后的内容。 - URL 伪协议型:
javascript:alert(1),验证链接类字段。
测试时重点看两个地方:一是接口响应体里这些字符有没有被编码,比如<是否变成了<;二是浏览器页面里是否出现了执行效果。如果接口返回 JSON,前端怎么渲染也要测,特别是富文本和图片相关的字段。
6.2 代码评审清单:Java 开发者需要盯住哪些点
我把代码评审时常用的 XSS 检查点整理成了一份固定清单,每次 Review 都对照着过一遍:
- 是否直接在 Controller 或 JSP 里用字符串拼接 HTML?如果有,必须改走模板引擎或编码。
- 模板输出用的是默认转义标签,还是绕过了转义的原始输出标签?绕过的地方必须有白名单方案。
- 前端拿后端 JSON 渲染时,用的是文本绑定还是
innerHTML?文本字段一律不直接用innerHTML。 - 富文本字段是否经过白名单过滤?
- 文件上传接口是否对文件类型做了内容级校验,文件访问时是否会以 HTML 方式渲染?
- 项目的统一响应头里有没有
X-Content-Type-Options和Content-Security-Policy? - Cookie 是否设置了
HttpOnly和SameSite? - 老系统里还有没有
<%= %>输出?有的话是否已纳入改造计划?
不少团队把安全评审当“走形式”,其实只要这几点逐条核对,XSS 的遗漏概率会大幅下降。
6.3 自动化防线与团队经验
除了人工评审,还要在工程化上做手脚。我的建议是至少加三道自动化防线:第一道,CI 流水线里跑静态代码扫描,专门识别“字符串拼接输出到响应”这类高风险写法;第二道,对第三方依赖做漏洞扫描,因为某些老版本库自身就存在可被 XSS 利用的问题;第三道,接口自动化测试里加入安全断言,比如请求带alert(1)参数时,断言响应体里不包含可执行的原始脚本片段。
最后再说合作机制。XSS 的修复往往卡在“前端觉得后端应该编码,后端觉得前端应该转义”这种责任归属上。我在团队里定的规矩很简单:后端负责保证接口返回的数据是业务层面的原始值,但凡是会把数据放进 HTML、JS、CSS 上下文里渲染的地方,由渲染方负责做对应的上下文编码。如果是后端渲染模板,编码责任在后端;如果是前端渲染,约定不直接用innerHTML,文本一律用文本节点或框架的插值语法。责任清晰之后,问题从“互相甩锅”变成“各管一段”,再配合上面的评审清单和自动化,XSS 从高危到低频是完全能做到的。
我个人的体会是:不要指望一次性把所有漏洞都修完,但一定要建立一套“发现一个、修复一类、复查一批”的机制。XSS 不是什么高深技术,它更像是工程习惯问题——只要每次代码评审都把“数据会去哪里、会被浏览器怎么解释”这个问题问一遍,很多漏洞在上线之前就能被按死在萌芽状态。