转义指南

HTML 实体与按上下文转义

为什么转义取决于值最终落在哪里、文本/属性/URL/JavaScript/CSS 各自的规则、命名实体与数字实体的差别,以及为什么按上下文做输出编码(而不是过滤输入)才是 XSS 的防线。

转义不是对字符串做一次、之后就不用再想的事。正确的变换完全取决于这个值将要落到哪里,而为某一种上下文转义好的值,换个地方往往仍然危险,或者干脆只是显示坏了。这篇指南把上下文列清楚,给出各自的规则、可用的实体形式,并说明为什么「过滤掉看起来危险的输入」从来就不是一条能成立的防线。

转义是目的地的属性,不是数据的属性

一份 HTML 文档不是一种语言。浏览器是用好几套解析器来读它的:处理标记的 HTML 词法分析器、处理 href 和 src 内容的 URL 解析器、script 元素与事件处理属性里的 JavaScript 解析器,以及 style 元素与属性里的 CSS 解析器。每一套对「哪些字符是结构」都有自己的看法,而且一旦交棒给下一套,前一套就不管了。

这正是「一个万能转义函数」不可能正确的原因。把尖括号换成实体,对元素文本来说完全正确,放进 script 块里则毫无作用 —— 那里危险的是引号和反斜杠,而实体在 JavaScript 字符串里只是七个普通字符。百分号编码对 URL 正确,对文本错误。要问的从来不是「这个字符串转义了吗」,而是「它是按即将插入的那个位置转义的吗」。

由此得到的实务结论是:转义属于输出环节,不属于输入环节。以转义态存储的值无法检索、无法比较、无法在另一种上下文中重新渲染,而且迟早会在有人加上第二层时变成双重转义。存原值,输出时再编码。

各上下文及其规则

值得单独命名的上下文有五种,外加一种应当直接视为禁区。元素文本最简单:词法分析器盯的是「开启标签的小于号」和「开启字符引用的与号」,所以这两个必须编码;顺手把大于号也编码上没有任何成本,还能避开注释和类 CDATA 序列的边角情况。

属性值是文本加上一个定界符。如果值在双引号内 —— 而它任何时候都应该在引号内 —— 那么与之匹配的引号字符必须编码,因为一个裸引号会提前闭合属性,它之后的内容会被当成新的属性来解析。不带引号的属性值则由空格、制表符、换行、换页、大于号以及另外几个字符来终止,所以不带引号的属性根本不应该承载插值数据。

JavaScript 和 CSS 上下文,是「手写转义」彻底失效的地方。在 script 元素内部,HTML 词法分析器仍然在盯着那个能结束元素的字符序列,所以只要值里含有一个闭合 script 标签,无论 JavaScript 字符串本身转义得多仔细,块都会被提前终止 —— 这就是为什么序列化成 JSON 是必要但不充分的,小于号还必须额外输出成 unicode 转义。在 CSS 里,最稳妥的姿态不是转义而是白名单:只接受你已经校验过的颜色或长度,绝不要把任意文本插进属性值,更不要插进 url() 引用。

最右列是「把值写进文档的那一刻」应当施加的变换。

上下文示例位置所需编码
元素文本<p>值</p>实体编码 & < >
带引号的属性<a title="值">实体编码 & < > " ',并且始终保留引号
不带引号的属性<a title=值>根本不要在这里插值
属性中的 URL<a href="/s?q=值">先百分号编码,再对结果做实体编码
脚本数据<script>var x = 值;</script>序列化为 JSON,并把 < 转义成 \u003c
CSS 值<style>a { color: 值 }</style>按白名单校验,不要插入自由文本
事件处理属性<a onclick="值">两套解析器叠加,视为禁区

命名实体与数字实体

字符引用可以用名字,也可以用编号。HTML5 定义了两千多个命名引用,从人人都知道的那五个,到各种数学与排版符号。数字引用用 Unicode 码点来指定字符,十进制写作 &#8212;,十六进制写作 &#x2014;,它对任何字符都适用,也不要求消费方认得某个名字。

对那五个具有结构意义的字符,用命名形式即可,但有一个例外值得记住:撇号。&apos; 在 XML 和 HTML5 中有定义,却不存在于 HTML 4,因此在较老的解析器和某些 XML 转 HTML 的流水线里可能解析不出来。数字形式 &#39; 则一直到处都能用,为单引号属性做转义时它是更可移植的选择。

除了这五个结构字符之外,命名实体更多是可读性上的便利,而非正确性上的要求。在 UTF-8 文档里,能直接写破折号就没必要写 &mdash;;实体真正有用的场合是:文件编码不确定、字符本身不可见而你希望它在源码里显眼、或者已知下游系统会把非 ASCII 弄坏。最后这一条正是 &nbsp; 至今活跃的诚实理由 —— 不换行空格和普通空格在编辑器里一模一样,写成实体才能让意图在评审中被看见。

两个机械性的细节造成了大多数实体 bug。分号是引用的一部分,永远不要省略,尽管出于历史兼容 HTML 解析器对某些不带分号的引用是容忍的 —— 这份容忍在元素文本和属性值里的表现还不一样,不值得依赖。另外,手工转义时必须先替换与号:如果把它放在最后替换,已经输出的 &lt; 会变成 &amp;lt;,读者看到的就成了转义序列本身而不是字符。

字符命名形式十进制十六进制
与号&amp;&#38;&#x26;
小于号&lt;&#60;&#x3C;
大于号&gt;&#62;&#x3E;
双引号&quot;&#34;&#x22;
撇号&apos;(HTML 4 中没有)&#39;&#x27;
不换行空格&nbsp;&#160;&#xA0;
软连字符&shy;&#173;&#xAD;
破折号&mdash;&#8212;&#x2014;

属性:引号本身就是转义的一部分

属性值和包住它的引号是一个整体机制。如果你确定值在双引号内,只编码双引号就足以把值圈住;但如果模板可能被改成单引号,或者代码生成器两种都会产出,那就必须把两种引号字符都编码。一律两种都编,成本是几个字节,换来的是消除一整类「有人重排模板后才冒出来」的 bug。

不带引号的属性比看上去更糟。规范规定不带引号的值在空白处终止,但浏览器还会把另外一批字符当作分隔符,而一个以特殊字符开头的值可以凭空引入一个全新的属性。要逃出不带引号的属性,载荷根本不需要尖括号或引号 —— 一个空格加上 onmouseover=... 就够了。这个上下文不存在安全的转义策略,所以规则是:给每一个属性加引号,没有例外。

还有一些属性无论怎么转义都危险,因为值本身就是代码或资源引用。任何以 on 开头的属性都是事件处理器,里面装的是 JavaScript。href、src、action、formaction 和 data 这类属性接受 URL,而以 javascript: 开头的 URL 无论字符串被实体编码得多完美,一点就执行。在那里转义是错的工具,校验协议头才是对的。

实体编码在哪里就不管用了
属性里经过正确转义的安全文本:
  <a title="Tom &amp; Jerry &lt;the cartoon&gt;">link</a>

实体编码过、却依然可执行,因为问题出在协议:
  <a href="javascript&#58;alert(1)">link</a>

不带引号的属性,连尖括号和引号都不需要:
  值:    x onmouseover=alert(1)
  输出:  <a title=x onmouseover=alert(1)>

解决办法不是把转义清单拉得更长:
  - 给每个属性值加引号
  - URL 属性只允许 http、https 和 mailto
  - 永远不要用数据拼出 on* 属性,改在代码里挂监听器

HTML 里的 URL 需要两层编码,且有先后

用用户数据拼出来的链接,同时身处两套语法之中。这个值必须先百分号编码,才能待在它自己的 URL 分量里;拼出来的 URL 又必须做实体编码,才能待在它自己的 HTML 属性里。漏掉任何一层都会出缺陷,顺序搞反则会出另一种缺陷。

顺序是:先百分号编码,后实体编码。对单个查询值做百分号编码,拼出完整 URL,然后在把它写进属性时对整串做实体编码。反过来做的话,作为分隔符的与号在最终标记里会变成 &amp;amp; 而不是 &amp;,于是查询串到达服务端时,第一个参数之后的每个参数名前面都多了一个字面的 amp; 前缀。

还有第三项检查,它根本不是编码。在 URL 进入 href 或 src 之前,必须拿它的协议头去比对白名单。百分号编码不能让 javascript:、data: 或 vbscript: 失效,实体编码同样不能。先解析 URL,确认协议在允许之列,不在就拒绝 —— 然后再编码。

用一个搜索词拼出链接
用户输入的搜索词:   Tom & Jerry <2026>

第一步,对值做百分号编码:
  Tom%20%26%20Jerry%20%3C2026%3E

第二步,拼出 URL:
  /search?q=Tom%20%26%20Jerry%20%3C2026%3E&page=1

第三步,为属性做实体编码:
  <a href="/search?q=Tom%20%26%20Jerry%20%3C2026%3E&amp;page=1">results</a>

顺序反过来(先实体后百分号)会得到 &amp;amp;page=1,
服务端收到的参数名就成了字面的 "amp;page"。

按上下文编码才是防线,过滤不是

每隔几年就会有人提议用「拒绝看起来危险的输入」来解决跨站脚本 —— 过滤掉 script 这个词、删掉尖括号、拦截 onerror 这个串。这条路从来没走通过,原因是结构性的,而不是「清单还不够好」。HTML 解析极其宽容:标签可以拆开写,属性可以不加引号,字符可以写成实体并在求值之前就被解析器还原,而能执行代码的属性集合还随着每一版规范在增长。黑名单是在一门「表达同一件事有无穷多种写法」的语言里枚举坏东西。

输出编码把问题反了过来。你不再去猜哪些输入危险,而是保证:无论输入是什么,它都以数据而非标记的身份被插入。这条保证对还没被发明出来的载荷同样成立,而这正是过滤器永远不可能具备的性质。推论是:编码必须按目的地来选 —— 这也是上面那张表存在的理由 —— 并且必须发生在尽可能晚的时刻,也就是模板里,而不是上游某个还不知道目的地的地方。

落到实践上,这意味着要依靠「理解上下文」的工具,而不是一个辅助函数。现代模板引擎默认转义并且知道自己身处哪种上下文;React、Vue 和 Angular 会自动转义插值,想输出原始 HTML 必须显式使用一个能被 grep 出来的出口。当确实必须接受用户提供的标记时 —— 富文本字段、Markdown 评论 —— 答案是一个真正的消毒器,带元素与属性白名单、作用在解析后的文档上,比如 DOMPurify,而不是一条作用在字符串上的正则。此外还值得配一份内容安全策略,作为上述任何一环被漏掉时限制损失的那一层。

最后要说清楚,这样一个页面上的工具能告诉你什么、不能告诉你什么。实体编码器把字符串转换一下,方便你查看或粘贴;HTML 预览把标记渲染出来,方便你看到它产出什么。两者都不审计你的应用:它们看不到你的模板,不知道某个值最终会落在哪种上下文,而一段预览看起来人畜无害,并不能证明生产环境里真正输出这个值的代码路径也是安全的。用它们理解机制,用代码评审、消毒器和 CSP 来保证安全。

  • 存原始值;在输出时、在模板里、只编码一次。
  • 按目的地上下文选择编码方式,而不是按输入长什么样。
  • 给每个属性加引号;URL 属性在编码之前先按白名单校验协议头。
  • 只通过基于解析器、带元素与属性白名单的消毒器来接受用户标记。
  • 把预览和编码工具当作检查辅助,永远不要当作安全检测。

要点回顾

  • 按值最终落点决定转义方式 —— 元素文本、属性、URL、脚本还是样式 —— 因为不存在一个对所有场景都正确的转义函数。
  • 在输出时而非输入时编码,让存储的值保持可检索,也让第二层无从造成双重转义。
  • 属性值一律加引号;手工转义时永远先处理与号;为可移植性优先用 &#39; 而不是 &apos;。
  • 做链接时先对值百分号编码、再对拼好的 URL 做实体编码,并单独校验协议头,因为没有任何编码能让 javascript: 失效。
  • 凡是必须接受标记的场景,都交给上下文感知的模板引擎和基于解析器的消毒器;预览工具只展示输出,不审计你的应用。

继续了解相关检查与工具