上下文决定一切:哪些字符该在哪里转义
转义不是字符串本身的属性,而是字符串落点位置的属性。HTML 有五个结构字符 — & < > " ' — 但每个渲染上下文关心的子集不同。在元素内容(标签之间)里,只有 & 和 < 能改变解析:< 打开标签,& 开始 character reference。在双引号属性值里,解析器只在 " 处结束取值,所以必须转义 & 和 ";单引号属性则需要 & 和 '。真正危险的是不加引号的(unquoted)属性值:它会被空格、Tab、换行、form feed 终止,还与 / 和 = 产生糟糕的交互,因此像 'x onmouseover=alert(1)' 这样看似无害的 payload 一个结构字符都不用就能逃逸。这就是所有安全指南都强调"属性永远加引号"的原因 — 需要转义的字符缩减为两个。
实体编码也有硬性边界。经过实体编码的字符串放进 <script> 块里仍然完全危险:HTML 解析器不会解码 script 数据里的 character reference,但字符串可能提前终结代码块或被当作 JS 解释。放在 href 里的 javascript: URL 能完整躲过实体编码 — 浏览器在解析 URL 之前会先解码实体。内联事件处理器在 JS 解析之上又叠加了一层 HTML 解码,两层转义缺一不可。OWASP XSS Prevention Cheat Sheet 精确列举了这些上下文;规则是按输出目的地选择编码器,而不是按数据本身。
<!-- element content: escape & and < -->
<p>Tickets < $20 & free entry</p>
<!-- double-quoted attribute: escape & and " -->
<input value="say "hi" & wave">
<!-- UNQUOTED attribute: breakout with none of the five chars -->
<input value=x onmouseover=alert(1)>
<!-- entity encoding does NOT help here -->
<a href="javascript:alert(1)"> <!-- decoded before URL resolution -->命名实体与数字实体 — 以及 XML 陷阱
WHATWG 的 HTML Living Standard 精确定义了 2,231 个 named character reference — 从 & — 这样的主力到 ∳ 这样的冷僻符号。任何 Unicode code point 也都可以用数字形式书写:十进制 → 和十六进制 → 都生成右箭头,而十六进制形式与 Unicode 码表的 U+2192 记法直接对应,更容易核对。
陷阱在于这 2,231 个名字是 HTML 独有的词汇表。XML 只预定义五个:& < > " '。把 交给 XML 解析器 — 以 application/xhtml+xml 提供的 XHTML 页面、RSS/Atom feed、当作独立 XML 处理的 SVG 文件 — 得到的不是警告而是致命解析错误。可移植的写法是  。反过来,' 在 XML 和 HTML5 中有定义,但从来不属于 HTML 4,所以老旧解析器和一些邮件渲染器会原样显示它;到处都能用的撇号是 ',这也是 PHP 的 htmlspecialchars 之类序列化器输出 ' 而非 ' 的原因。
手动解码时记住 named reference 区分大小写:Ä 是 Ä,ä 是 ä;& 存在但 &AMp; 不存在。先把输入转小写再查表的解码器会悄悄损坏文本。
Rightwards arrow (U+2192):
named: → (HTML only)
decimal: →
hex: → (matches U+2192)
Safe everywhere (HTML + XML/RSS/SVG):
& < > " '  
Fatal parse error in XML: — © ...缺失的分号、URL 里的 & 与双重编码
HTML 解析器至今仍认得一份不带分号的旧式 named reference 列表 — &、<、© 等 — 因为 HTML5 之前的内容依赖它。这种宽容造就了一个经典 URL bug:在 ?page=1©=2 中,© 序列在历史规则下会被解码成 ©,悄悄破坏查询字符串。现代规范用属性值例外(旧式 reference 后面跟 = 或字母数字时保持原样)修补了它,但教训仍在:href 和 src 里的每个裸 & 都应写成 &。校验器至今仍标记 URL 中的裸 ampersand,原因正在于此;浏览器发请求前会把 & 还原成 &,所以这样写没有任何代价。
双重编码是反方向的失败。把 < 再编码一次得到 &lt;,页面上渲染出来的不是 < 而是字面文本 <。当你在页面里看到 &quot; 或 &amp;,说明有两层各自"好心地"转义了一次:通常是模板引擎对已经手工编码过的输出又做了自动转义,或者 HTML 在写入数据库前被实体编码、渲染时又编码了一遍。防住整类问题的黄金法则:存储原始文本,在输出时刻、用匹配目标上下文的编码器、恰好编码一次。像本工具这样的解码器是诊断字符串被套了几层的最快办法 — 反复解码直到输出不再变化,数一数次数即可。
?a=1©=2 legacy parsing: "a=1©=2" (broken)
?a=1&copy=2 safe: browser requests "a=1©=2"
"<b>" encoded once → "<b>" (correct)
encoded twice → "&lt;b&gt;" (renders "<b>")有了 UTF-8,你需要的实体远比想象中少
如果页面声明了 <meta charset="utf-8"> — 2026 年理应如此,超过 98% 的网页以 UTF-8 提供 — 那么 em dash、弯引号、箭头、CJK、emoji 都不需要实体。直接输入 — 而不是 —,源码更小(1–3 字节对 7 字节),更重要的是可读:满屏 → 的 diff 没法审。仍然需要实体的字符分两类。第一类是那五个结构字符,永远需要。第二类是在编辑器里不可见或无法分辨、留成字面字符很危险的:no-break space( — 字面的 U+00A0 和普通空格看起来一模一样,下一个人的编辑器会把它"修正"掉)、软连字符 ­、用于 emoji 序列和波斯语/印度系文字塑形的 zero-width joiner ‍ 与 zero-width non-joiner ‌,以及 bidi 控制符。把这些写成实体本身就是自我注释。
一个提醒:如果你输入 — 的地方渲染出 — 这样的乱码,说明文件用错误的编码保存或传输了。实体只会掩盖这个 bug,修正 charset 才是真正的解法 — 用 — 糊住的 mojibake,会在有人粘贴 non-ASCII 内容的那一刻卷土重来。
textContent、innerHTML,以及何时该用净化器
在 DOM 里,最安全的编码器是根本不用调用的那个:element.textContent = userInput 把字符串当纯文本插入 — 不解析、不需要编码,任何嵌套深度都安全。element.innerHTML = userInput 则是教科书式的 XSS sink:字符串会完整走一遍 HTML 解析器,里面的任何标记都会被执行。(setAttribute 对值同样安全,但属性本身很关键 — setAttribute('href', 'javascript:...') 仍是一个活的 URL。)
模板框架把这条教训内建了:Svelte 的 {expression}、React 的 JSX、Vue 的双花括号插值全部默认转义,所以把数据先手工编码再交给组件不是加固,而是双重编码 bug。每个框架也都留了显式逃生口 — Svelte 的 {@html ...}、React 的 dangerouslySetInnerHTML、Vue 的 v-html — 这些刻意别扭的命名标记着责任转移给了你。
由此引出最后一个区分:编码和净化(sanitize)解决的是不同问题。编码把标记变成可见文本 — 适用于用户输入了 HTML 且应该原样看到的场合。净化(DOMPurify,或正在落地的原生 Sanitizer API)则解析标记,保留安全标签的 allowlist 并剔除脚本向量 — 适用于用户正当撰写富文本且必须按标记渲染的场合。给评论字段编码、给 WYSIWYG 正文净化,两者都对;调换过来要么显示错乱,要么开出漏洞。
// safe: never parsed as markup
el.textContent = userInput;
// XSS sink: parsed and executed
el.innerHTML = userInput;
// frameworks escape by default; escape hatches opt out
// Svelte: {comment.text} vs {@html post.trustedHtml}
// React: {comment.text} vs dangerouslySetInnerHTML
// Vue: interpolation vs v-html
// rich text: sanitize, don't encode
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml);