搞过企业级应用的人大概率都经历过类似的深夜:线上有人反馈某个管理后台页面弹了一个诡异的 alert,你打开浏览器控制台一查,用户昵称里带着一段没被过滤的<svg/onload=...>。这种场景我碰过不止一次,而且每次复盘都会发现同一个规律:很多人把 XSS 防护当成一个“过滤函数”,写了个 replace 就把安全验收交差。
这篇文章想聊的,是一套开源微服务权限系统(下文统一叫 P 系统)里真正把 XSS 防成“一条链”的做法。它不是只靠一个配置文件,也不是靠某个“安全组件”一装了之,而是在请求入口、数据输出、响应协议、会话令牌这四个层面分别设卡,然后再把这些点串成一条纵深防线。如果你正好在维护权限类 Web 应用,或者想给自己的项目做一次 XSS 防护升级,这篇内容应该能帮你补上不少漏掉的死角。
1. 别把“零漏洞”当成营销词,先看清权限系统的攻击面
1.1 XSS 在这类系统里的真实分布
权限管理系统有一个天然特性:页面多、角色多、动态渲染多。三个 XSS 大类在里面都能找到对应位置:
| 攻击类型 | 典型入口 | 危害场景 |
|---|---|---|
| 反射型 | 搜索、报表条件、分页参数 | 攻击者诱导用户点击恶意链接,带着认证状态触发接口后,页面把参数直接回显 |
| 存储型 | 用户昵称、签名、菜单名称、公告内容 | 恶意脚本被写进数据库,每次有人浏览都会执行,危害最持久 |
| DOM 型 | 前端路由 hash、本地存储、接口返回值拼 DOM | 后端接口未必有问题,但前端某个innerHTML赋值点成了突破口 |
P 系统里最容易被盯上的模块,反而不是登录页,而是两个地方:一是用户资料和用户列表,因为接口会批量返回头像、昵称、角色标签,前端经常直接渲染;二是站内信、公告这类富文本模块,因为富文本天然需要允许一部分 HTML 标签,一旦白名单写得不够细,onerror、javascript:就顺着进来了。
另外还有一个容易踩的地带:操作日志和审计列表。很多系统会把“请求地址、请求参数”原样记录下来并展示到管理界面,如果攻击者在 URL 里塞了编码后的脚本,日志页就变成了一个存放恶意负载的蓄水池。这类问题很难在功能测试阶段暴露,但确实存在。
1.2 “零漏洞”是一个工程目标,不是一个交付结果
“零漏洞守护你的 Web 应用”这种描述,更适合理解为一个安全设计目标:把漏洞概率压到足够低,而不是真的拍胸脯保证绝对没有。原因很简单,XSS 的绕过技巧是会随着浏览器更新、框架变化而迭代的,前几年常用的过滤思路,放到今天可能就失效了。
我见过很多团队的防护方式停留在两个阶段:
- 阶段一:在入口用正则替换
<script>、alert这类敏感词。这种黑名单思路十次有九次翻车,因为攻击者只要换一种编码、换一个标签就绕过了。 - 阶段二:在应用层加一个全局过滤器,把所有参数里的
<、>替换成实体。这比阶段一好不少,但富文本场景会直接坏掉,而且如果前端存在v-html、innerHTML这类危险渲染点,后端过滤做得再好也可能被带偏。
P 系统真正值得借鉴的,是把防线拆成了四层:输入侧清洗、输出侧编码、响应侧安全头、会话侧令牌保护。每一层都有可能被单独绕过,但想四层全部绕过,攻击成本会指数级上升。安全工程里,我们追求的不是某一把锁绝对打不开,而是让撬锁的人撬到一半就要放弃。
2. 输入侧把关:在请求进入业务代码之前就清洗数据
2.1 为什么选择 Filter,而不是 Interceptor 或注解
做输入清洗时,首先要选对拦截位置。Spring MVC 的 Interceptor 只能拦截进入 Controller 的请求,但如果是其他框架的接口、WebSocket、定时任务内部的参数,或者某些直接读取HttpServletRequest的组件,拦截器就会漏掉。Servlet 的 Filter 是容器层面的机制,只要请求从客户端进来,就一定会经过过滤器链,覆盖面最全。
P 系统的实现路径比较典型:定义一个XssFilter,在过滤链上把所有HttpServletRequest包装成自己写的安全请求对象,然后继续向下传递。这样上游只负责做一件事:脏数据尽量在源头扭转。
public class XssFilter implements Filter { private final XssCleaner xssCleaner; public XssFilter(XssCleaner xssCleaner) { this.xssCleaner = xssCleaner; } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; XssHttpServletRequestWrapper wrapper = new XssHttpServletRequestWrapper(httpRequest, xssCleaner); chain.doFilter(wrapper, response); } }选 Filter 还有一个好处:配置成本低。不管项目后续是单体还是拆成微服务,只要你在网关或应用入口保留这个 Filter,所有服务都能共享同一套清洗逻辑。
2.2 包装器要覆盖 getParameter、getHeader 和 JSON Body 三个盲区
很多初学者写请求包装器时,只重写了getParameter方法,结果发现从 Header 和 Body 进来的脏数据还是拦不住。一个完整的清洗包装器至少要覆盖三条路径:
- getParameter / getParameterValues:处理普通表单参数和查询参数;
- getHeader / getHeaderValues:处理自定义请求头里的用户可控值;
- getInputStream / getReader:处理 POST 的 JSON 或 XML body。
P 系统的做法是把清洗逻辑封装成一个XssCleaner,包装器内部统一调用它。这里有个细节:body 只能读取一次,如果你在 Filter 里先读取了输入流,后续 Controller 再用@RequestBody拿数据就会报“流已关闭”之类的问题。解决办法是用一个包装类把 body 内容缓存到内存字节数组里,每次调用getInputStream时重新构造流。
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { private final XssCleaner xssCleaner; private byte[] cachedBody; @Override public String getParameter(String name) { String value = super.getParameter(name); if (value == null) { return null; } return xssCleaner.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] = xssCleaner.clean(values[i]); } return cleaned; } @Override public String getHeader(String name) { String value = super.getHeader(name); return value == null ? null : xssCleaner.clean(value); } // getInputStream 和 getReader 使用 cachedBody,并逐次执行 clean }为什么不直接在所有入口统一replace("<", "<")?因为这样会破坏业务数据。比如用户搜索的关键词是“a<b”,你一清洗变成“a<b”,后端再按原文去数据库里查,就查不到结果了。所以,P 系统的清洗策略做的是一套兼顾安全与业务的区分:可执行标签和危险属性直接剥离,普通符号按上下文编码。
2.3 富文本内容不能一刀切,要按标签白名单过滤
权限系统里几乎都有“公告管理”“消息中心”这种需要富文本编辑器的地方。如果输入侧把所有标签一律干掉,产品经理第一个不同意。但如果放宽限制,攻击者很容易在富文本里嵌入<img src=x onerror=alert(1)>或者<a href="javascript:void(0)">。
P 系统的做法是引入白名单机制,而不是黑名单。黑名单永远追不完,白名单则是“只允许你显式列出来的标签和属性存在”。实用的 HTML 清洗工具很多,其中基于标签解析器的方案比正则表达式可靠得多。正则表达式无法正确处理嵌套标签、标签大小写、属性引号等边界情况,而解析器能把 HTML 变成一棵树,逐节点判断。
Safelist safelist = Safelist.relaxed() .addTags("p", "span", "br", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td") .addAttributes("a", "href", "target", "rel") .addAttributes("img", "src", "alt", "title", "width", "height") .addProtocols("a", "href", "http", "https", "mailto") .addProtocols("img", "src", "http", "https", "data");需要注意几点:
- 不要允许
style属性。CSS 里有过expression()这类危险用法,现代浏览器限制很多,但仍可能被拿来做数据渗透或钓鱼伪装。 - 对
a标签的href要限制协议。只允许 http、https、mailto 等安全协议,禁止javascript:。 img的src要限制协议。data:协议在某些场景下会触发敏感信息外带,建议只在可控的小场景里放行。- 清洗动作要在服务端做,前端预览只是展示层,服务端存下来的必须是干净数据。
2.4 防绕过:大小写、编码、事件属性一个都不能漏
即使是白名单清洗,也拦不住所有问题。比如 HTML 允许标签和属性名大小写混写,攻击者会把<IMAGE SRC=x ONERROR=alert(1)>混合大小写,清洗时如果先做大小写归一化再匹配,就会漏掉。
P 系统在清洗逻辑里有一组基础动作:
- 统一转换大小写,再做标签识别;
- 剥离所有以
on开头的事件属性; - 剥离
style、srcdoc、formaction等高风险属性; - 对
href、src、action这类带 URL 的属性做协议校验; - 对标签属性值的引号、括号、分号等做编码处理。
这些规则拆开看都很简单,但组合起来就形成了有效的过滤链。如果在开发时没有把这些规则写进自动化测试里,后面一旦有人调整清洗正则,很容易引入回归漏洞。
3. 输出侧编码:数据就算进来了,也要让它无害化
3.1 全局序列化层统一转义,而不是在每个 Controller 里手动处理
输入侧清洗的理想状态是把大多数恶意内容挡在门外,但现实情况是,总会有一些数据绕过或不在清洗范围里。比如,数据库里之前已经存了历史脏数据,或者上游系统通过接口直接写入了一段未过滤内容。这时候,输出侧编码就是第二道保险。
P 系统在后端 JSON 输出时做了全局转义。具体来说,就是定制一个Jackson ObjectMapper,把所有字符串字段的<、>、&、"、'编码成对应的 HTML 实体。这样前端即使拿到了原始数据,也无法把内容当作可执行的 HTML 标签。
public class XssObjectMapper extends ObjectMapper { public XssObjectMapper() { HtmlCharacterEscapes escapes = new HtmlCharacterEscapes(); getFactory().setCharacterEscapes(escapes); } }HtmlCharacterEscapes本质上是告诉 Jackson 在序列化字符串时,遇到这几个危险字符就转义。这里有一个必须想清楚的问题:如果你把所有字段都转义了,富文本字段也会变成一串<p>,前端显示就错了。
P 系统的设计是分层处理:普通字段在 JSON 序列化时全局转义;富文本字段单独标记,序列化时不转义,但在进入数据库之前,已经由输入侧的 JSoup 白名单清洗过一轮。这样既保证了展示正确,又控制了安全风险。
3.2 前端框架的自动转义是兜底,不是免死金牌
Vue 和 React 默认的插值表达式都是自动转义的,这是框架为开发者兜的底。但问题是,总有人需要使用富文本或拼接 HTML,于是引入了v-html、dangerouslySetInnerHTML。P 系统内部有个约定:所有用到v-html的地方,必须过团队代码评审,并且只能接受后端类型明确标记为“富文本内容”的字段。
我见过的一个反例,是开发者为了图方便,把后端返回的公告标题也塞进v-html里。标题本身没有富文本需求,但攻击者如果在后台把公告标题改成<img src=x onerror=alert(document.cookie)>,只要这个标题一旦被渲染,脚本立刻执行。后来团队把规则收紧为:
- 能用文本插值的地方,绝不使用 HTML 渲染;
- 第三方库需要传入 HTML 时,必须走同一个白名单清洗组件;
- 在线富文本编辑器提交的内容,不允许在提交前只做前端清洗,必须交给服务端再做一次清洗。
3.3 模板引擎同样要区分 text 和 utext
有些权限系统的管理端还在用服务端模板渲染,比如 Thymeleaf。Thymeleaf 里th:text会自动 HTML 转义,而th:utext是原样输出。任何非受信字段用th:utext输出,都会直接破坏防线。
P 系统的模板渲染规则是:除了少数由运营后台专门维护的富文本内容,其他所有字段一律走th:text。涉及到富文本的地方,输出时仍然要过一遍 JSoup 的白名单,而不是直接把数据库内容交给th:utext。原因很简单,数据库里存的白名单数据是录入时清洗的结果,它不能阻止前后端在传输过程中被篡改,输出前重新清洗是最稳妥的。
4. 浏览器端防线:用 CSP 和 Cookie 属性把最后一道门锁上
4.1 CSP 配置落地的正确姿势
Content Security Policy 是目前浏览器层面最强的 XSS 缓解手段之一。它告诉浏览器“这个页面只能加载哪些来源的脚本、样式、图片”,即使前端某个渲染点被突破了,外部恶意脚本也无法被加载执行。
P 系统在网关层对响应头统一增加了 CSP。下面是一份比较适合权限系统的配置:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'配置里有几处需要特别解释:
script-src里的unsafe-inline会让 CSP 形同虚设。但很多管理系统确实依赖内联脚本,因为打包工具会把一段引导脚本直接嵌进 HTML。如果要保持安全,最好调整打包配置,把脚本全部外链,然后移除unsafe-inline。- 如果前端用了 Vue 或 React 的运行时编译,可能需要
unsafe-eval。现代脚手架一般默认不允许,但老项目可能留了这个口子,建议优先去掉并适配。 object-src 'none'是很多人容易漏掉的,为了兼容老浏览器,Flash 和插件类的攻击面仍然存在。- 上线 CSP 之后一定要监控
report-uri或者report-to。你会收到很多误报,但也能看到真实攻击。如果生产环境直接强制执行,可能会把某些第三方统计脚本、客服组件搞挂。建议先跑一段时间的 report-only 模式再强制。
4.2 旧版 X-XSS-Protection 头,建议不要盲目开启
以前很多系统喜欢加X-XSS-Protection: 1; mode=block。实际上,现代浏览器比如 Chrome 已经移除了对 X-XSS-Protection 的支持,这个头更多是历史遗留产物。在某些旧版浏览器里,它还可能因为检测逻辑误判而阻断正常功能。
P 系统的安全头列表里保留的是Content-Security-Policy、Referrer-Policy、X-Content-Type-Options: nosniff、X-Frame-Options,以及Strict-Transport-Security。这几个头的作用更明确:
| 响应头 | 值 | 作用 |
|---|---|---|
| X-Content-Type-Options | nosniff | 防止浏览器猜测资源类型,降低 MIME 嗅探攻击风险 |
| X-Frame-Options | DENY 或 SAMEORIGIN | 禁止页面被 iframe 嵌套,减少点击劫持风险 |
| Referrer-Policy | strict-origin-when-cross-origin | 限制外带流量中携带的来源信息 |
| Strict-Transport-Security | max-age=63072000 | 强制 HTTPS,防止协议降级 |
4.3 让 Cookie 在 XSS 面前“无利可图”
XSS 攻击的很多目标是偷 Cookie,拿到登录态。如果 Cookie 设置了HttpOnly,JavaScript 就读取不到document.cookie,等于把会话令牌从脚本可见范围里拿走了。加上Secure属性,Cookie 只会在 HTTPS 连接中传输。再配合SameSite=Lax,可以缓解部分跨站请求伪造。
Spring Boot 里配置非常简单:
server: servlet: session: cookie: http-only: true secure: true same-site: lax如果自研了 Token 认证而不是 Session,也建议在设置 Cookie 时带上这些属性。还有一种做法是把 Token 放在Authorization请求头里,而不是 Cookie 里,这样即使发生 XSS,攻击者拿到的也只是当前页面里的脚本执行权限,而不是完整的凭证操作权限。
4.4 网关处的 add_header 容易踩继承坑
在 Nginx 里配置安全头时,很多人会踩一个隐蔽的坑:如果某个 location 或 server 块里出现add_header,它会把上一层的add_header全部覆盖,而不是继承追加。也就是说,你在 server 级别加了安全头,但下面某个 location 又单独加了一个静态资源缓存头,安全头可能全部丢失。
正确做法是,把安全头统一放在所有location都生效的层级,或者显式在每一层把需要的头都写全。P 系统的网关做法是放到最外层 server 块,并且在每个涉及接口路由的 location 里重新声明要继承的头,同时在 CI 里加了一个简单的脚本,自动检查响应里是否存在必选安全头。
5. 绕过测试实录:这些坑我第一次遇到时也没想到
5.1 编码绕过:过滤器不是万能的
P 系统第一次做 XSS 专项测试时,团队准备了一批常见绕过 payload。这里列几个经典的,方便你验证自己的防护链路:
| 绕过姿势 | 示例 | 说明 |
|---|---|---|
| 大小写混淆 | <ImG SrC=x OnErRoR=alert(1)> | 过滤规则如果区分大小写就会漏 |
| HTML 实体编码 | <script>alert(1)</script> | 输出位置在 HTML 属性里时,浏览器会解码后再解析 |
| URL 双重编码 | %253Cscript%253E | 网关解一次,应用层解一次,检测层可能只看第一层 |
| 标签嵌套 | <scr<script>ipt> | 如果清洗逻辑只做简单剥离,可能拼接出可执行标签 |
| href 协议绕过 | <a href="javascript:alert(1)"> | 属性值里做实体编码后,浏览器解码为 javascript: |
| 突变型 XSS | <noscript><p title="</noscript><img src=x onerror=alert(1)>"> | 浏览器解析时会把标签重新组合,过滤器和页面解析器结果不一致 |
测试后发现,单纯的正则黑名单完全扛不住这些姿势。最早的实现把script、onerror直接替换成空字符串,结果攻击者用<scr<script>ipt>这个嵌套写法,在过滤后反而拼出了一个完整标签。后来全部改成基于标签解析器的白名单方案,这类问题才彻底堵住。
5.2 手工测试要覆盖三个输出位置
XSS 检测不能只看接口返回值里有没有<script>,关键要看输出出现在 HTML 的哪个位置。同一个 payload 在 HTML 元素内容里、HTML 属性里、JavaScript 字符串里,执行条件和编码规则都不一样:
- 如果参数被拼进
<div>的文本内容,<script>一般不会执行,但<img onerror>会; - 如果参数被拼进
value="..."这类属性里,需要闭合引号才能执行,所以输出侧必须处理"和"; - 如果参数被拼进
<script>标签内部的 JS 字符串里,HTML 实体编码不再起作用,必须做 JS 上下文转义。
P 系统在测试用例里会把这三种位置分别写成独立用例,断言每个位置都返回转义后的内容。这里有一个很实用的技巧:不要只看“dangerous 字符是否被替换”,而是要看浏览器最终的 DOM 结构。可以用无头浏览器或浏览器开发者工具,把接口返回值注入到一个临时页面里,观察是否出现了img、script这类可执行节点。
5.3 误杀与性能:清洗不是越激进越好
输入侧过滤做过头,很容易影响正常业务。最常见的误杀是搜索功能:用户搜索“C++”,清洗规则把+替换掉,结果返回空数据。P 系统最终的清洗策略并不是对所有参数无差别处理,而是维护了一份“可执行标签/属性黑名单”全局清洗,普通文本字段优先剥离危险属性和协议,不做全量标签替换。
性能方面,JSoup 解析 HTML 的成本不低。如果每个请求的大字段都做一次白名单解析,接口延迟会明显上升。P 系统做了一套简单的分级策略:
- 普通字符串字段:走轻量级正则 + 协议校验,CPU 开销几乎可以忽略;
- 富文本字段(比如公告内容、站内信正文):走完整 JSoup 白名单清洗;
- 大文本字段只处理前 N 个字节,因为恶意负载一般出现在开头;
- 清洗逻辑不重复执行,入口 Filter 清洗完,业务层不再二次清洗,避免字符串被多次转义后出现数据错乱。
5.4 误杀之外,还要小心数据被二次加工
曾经遇到过一个诡异问题:用户昵称在数据库里显示正常,但页面展示时出现了乱码。排查后发现有两条链路都在做转义。第一条链路是入口清洗时把<变成了<,第二条链路是输出 JSON 序列化时又转义了一次,最终页面上展示的就是&lt;。这类问题的本质是转义函数被重复执行。
P 系统的经验是:把“清洗”和“编码”职责分开。清洗只在输入侧做,编码只在输出侧做,并且要保证同一个数据不会连续走两次同类处理。如果确实需要对某个字段做二次加工,必须使用对应的反转函数恢复原义,否则时间长了数据就烂掉了。这个坑其实很多大一点的团队都会踩,建议在代码评审时专门盯住“谁在多链路里写了自己的 replace”。
6. 把安全行为固化进开发流程,比任何组件都重要
6.1 上线前的 XSS 巡检清单
P 系统每次发版前都会跑一遍固定的巡检清单。我把这份清单简化后列出来,可以直接作为你项目的安全评审模板:
| 检查项 | 检查方式 | 评价标准 |
|---|---|---|
| 新接口是否经过全局 XssFilter | 查看过滤器配置和路由配置 | 所有/api/**都进入过滤链 |
是否有新增v-html/dangerouslySetInnerHTML | 搜索代码仓库 | 数量为 0 或全部经过评审 |
| 新增字段是否为富文本 | 看数据库字段和 DTO 定义 | 若无白名单清洗,禁止直接输出 |
| 响应头是否包含 CSP | curl 接口看响应头 | 存在 CSP 且没有危险源 |
| Cookie 是否 HttpOnly | 浏览器开发者工具查看 | 会话令牌不可被 JS 读取 |
| 安全头是否覆盖所有路径 | 遍历主要路由 | 不存在 add_header 被覆盖导致丢失 |
这份清单不是安全检查完就归档,而是会挂到一个固定的 CI job 里,每次都自动跑一遍。人工评审的主观性太强,机器能帮我们守住边界。
6.2 代码评审里最该盯的三句话
代码评审时,遇到下面这三类代码,我会要求团队停下来,逐行看上下文:
innerHTML/outerHTML/insertAdjacentHTMLdocument.write/evalv-html/dangerouslySetInnerHTML
这三类 API 只要出现,就意味着开发主动放弃了框架的自动转义保护。它们不是不能用,而是必须配合服务端的白名单清洗和数据源信任标注。我见过很多安全问题,不是安全组件不好,而是开发者在一个别人不知道的角落里调用了危险 API,安全组件覆盖不到这里。
6.3 我在实际维护中的几点体会
这套机制上线后,最明显的变化不是“漏洞变少了”,而是“排查变快了”。以前出问题,团队要翻半天日志才能定位是哪个渲染点出了问题;现在有统一的清洗组件、统一的输出转义、统一的响应头,出现了可疑 payload,能很快判断是哪个链路漏过去。
我也建议你在做 XSS 防护时,不要迷信某一个安全组件或一份配置文件。真正牢固的防护,是让每一层都承担一小部分职责,并且每一层都能独立检验。入口清洗掉了 90% 的危险数据,输出编码补上剩余的大多数,CSP 和 Cookie 属性又在浏览器侧收住最后的口子。三层链路串起来之后,单点失效并不会演变成安全事故。
另外还有两个小习惯我一直在用:一是在公司内部建一个“XSS 攻防测试样本库”,每次上线前自动跑一遍,样本库会随着外部披露的绕过技术持续更新;二是把安全配置纳入配置中心管理,不要散落在各个服务各自的配置文件里,否则很难审计,也很难统一调整。这个逻辑对任何有多个微服务、多渠道前端的团队都适用。
如果让我总结一句最想说的话,那就是:XSS 防护不是某一次开发任务,而是一种默认的编码习惯。习惯一旦形成,哪怕以后不用这套系统,写出来的代码也会自然而然地避开最危险的渲染路径。