页面上冒出  :二次转义是怎么发生的,以及转义到底该放在哪一层

· 约 5 分钟 🏷️ HTML 实体编解码

页面上出现了这样的东西:

张三 李四          ← 本该是一个空格,却显示成五个字符
产品 A & 产品 B     ← 本该是 &
          ← 这个更离谱

三种都是同一个病:这段文本在从输入到输出的路上,被转义了不止一次。

先数一数转了几次

规律很整齐,因为转义本身是可叠加的:

原文转 1 次转 2 次转 3 次
&&&&
   
<p>&lt;p&gt;&amp;lt;p&amp;gt;

每多转一次,就多一层 amp;

实操方法:把那段文本粘进 HTML 实体编解码反复解码直到结果不再变化,解了几次就是被转了几次

一个例外要留意:如果这段内容本来就在讲 HTML 转义(比如这篇文章本身),那它「应该」带着 &amp;,被转成 &amp;amp; 才是对的。所以多一层不等于一定是 bug,要结合数据本身是什么来判断。

五种常见的来源

数出层数之后,下一步是找出链路上有几个转义点。按出现频率排:

1. 入库前转义 + 模板引擎输出时又转

最常见的一种。后端出于「防 XSS」的想法在写库前转义了一次,而现代模板引擎(Vue、React、Thymeleaf、Jinja2 的自动转义)在输出时默认还会转一次

两次相加,页面上就出现了字面量的实体。

2. 富文本编辑器的内容被当成普通文本

编辑器存出来的本来就是 HTML,结果被当成普通文本转义了一次,渲染时模板引擎再转一次——用户看到满屏的 &lt;p&gt;

3. 接口层已经转好了,前端又转

后端返回的 JSON 里已经是 &amp;,前端用 textContent 赋值(不解码)或者再做一次转义。

4. 多个系统接力,每一层都「保险起见转一下」

A 系统传给 B,B 传给 C,每一层的作者都不确定上游做没做,于是都做了。这种最难查,因为每一层单看都很合理

5. 日志、导出、二次采集

从页面上抓下来的内容本身就是转义后的,再存回库里就多了一层。

转义到底该放在哪一层

一条原则可以解决上面全部五种:

存原文,在输出的那一刻按目标上下文转义一次。

英文叫 output encoding。它的对立面——在输入端转义(input sanitization)——是错的,理由有三条:

一、你不知道它将来会去哪。 同一段文本的四种去处,规则完全不同:

输出到该用什么
HTML 正文HTML 实体转义
<script> 里的 JS 字符串JS 字符串转义,HTML 实体在这里完全无效
URL 参数URL 编码
CSV / 短信 / PDF / 日志一个都不要转

在输入端转义,等于提前替所有下游做了决定,而且做错了。

二、搜索会失灵。 用户搜「张三&李四」,库里存的是 张三&amp;李四,匹配不上。

三、长度统计失真。 一个 & 占五个字符,字数限制、截断、摘要全乱。

富文本是另一条路

需要区分两类字段:

字段类型将来要以 HTML 渲染吗该做什么
普通文本(用户名、标题、评论)输出时转义
富文本(文章正文、编辑器内容)白名单清洗

富文本不能转义——转了标签就变字面量了。它要做的是 sanitize:用成熟的库按允许的标签和属性过一遍,去掉 <script>on* 事件属性、javascript: 协议的链接。

清洗放在输出时或入库时都可以,但只做一次,并且记清楚做在哪一层

修历史数据的那个陷阱

假设你已经有了一批二次转义的数据,想批量解码修回来。这是这一整件事里最危险的操作。

风险在于:批量解码时很难区分两种数据

  • 被转了两次的 —— 该解一次
  • 只转了一次、但原文本身就含实体的 —— 不该解

对后者多解一次:

库里:  &amp;lt;script&amp;gt;
解一次:&lt;script&gt;
输出:  <script>              ← XSS 回来了

你亲手把注入放了回去。

稳妥的做法是三步:

  1. 先在输出端做兼容,让页面立刻正常,争取时间
  2. 抽样统计,确认这批数据的转义层数是否一致——不一致就不能统一处理
  3. 按时间段或数据来源分批,用 &amp;amp; 这类明确的双层特征做筛选条件,而不是无差别地对全表解一次

改完一定要过一遍安全校验。不要假设解码是个无害操作。

顺带:转义解决不了的三件事

即使转义时机完全正确,也还有三个它管不到的地方:

一、属性值必须有引号。 <div title=用户输入> 这种无引号属性,即使转义了五个字符,攻击者仍然可以用空格加上 onerror= 注入。

二、<script> 里要用 JS 转义。 HTML 实体在 JS 解析器眼里就是普通字符,&lt; 不会变成 <,但也挡不住 </script> 提前闭合标签。

三、URL 要用 URL 编码。 而且 href="javascript:..." 这类协议要单独拦,编码解决不了。

一句话:实体转义解决的是「HTML 解析器怎么读这段文本」,管不到 JS、URL 和 CSS 解析器。

相关

❓ 常见问题

怎么判断一段文本被转义了几次?

amp; 的层数规律:原文里的 & 转义一次变 &amp;,转两次变 &amp;amp;,转三次变 &amp;amp;amp;——每多转一次,就多一层 amp;其他实体同理&nbsp; 转一次变 &amp;nbsp;,页面上会显示成字面的 &nbsp; 五个字符。实操:把那段文本粘进 HTML 实体编解码 反复解码,解到结果不再变化为止,解了几次就是被转了几次注意一个例外:如果原文本身就包含字面的 &amp;(比如一篇讲 HTML 转义的教程),那它「应该」被转成 &amp;amp; 才对——所以看到多一层不能立刻断定是 bug,要结合这段数据本来是什么来判断。

为什么不能在数据入库前就转义好?

因为你不知道它将来会被输出到哪个上下文同一段文本的四种去处,转义规则完全不同:(1) HTML 正文 → HTML 实体;(2) <script> 里的 JS 字符串 → JS 转义,HTML 实体在这里完全无效;(3) URL 参数 → URL 编码;(4) CSV、短信、PDF、日志 → 一个都不要转。入库时转义的三个连带问题:(1) 搜索失灵 —— 用户搜「张三&李四」,库里存的是 张三&amp;李四,匹配不上;(2) 长度统计失真 —— 一个 & 占了五个字符,字数限制全乱;(3) 一旦下游又转一次就是二次转义,而此时已经分不清哪些是原文自带的。正确原则存原文,在输出的那一刻按目标上下文转义一次

富文本编辑器存出来的内容该怎么处理?

它和普通文本输入要分开对待,因为它本来就该包含 HTML 标签做法:(1) 不要对它做 HTML 实体转义 —— 转了之后标签变成字面量,页面上会显示一堆 <p>;(2) 改用白名单清洗(sanitize) —— 用成熟的库按允许的标签和属性过一遍,去掉 <script>on* 事件属性、javascript: 协议的链接;(3) 清洗放在输出时或入库时都可以,但只做一次,并且记清楚做在哪一层。最常见的事故:编辑器内容被当成普通文本转义了一次,渲染时又被模板引擎转了一次——用户看到满屏的 &lt;p&gt;判断方法:这个字段将来要不要以 HTML 渲染?要,走清洗;不要,走转义。

已经存了一批二次转义的数据,能批量解码修回来吗?

能,但这是这一整件事里最危险的操作风险在于:批量解码时很难区分「被转了两次的」和「只转了一次、原文本身就含实体的」。对后者多解一次,&amp;lt;script&amp;gt; 会变成 &lt;script&gt;,再经过一次正常输出就成了真正的 <script>——你亲手把 XSS 放回去了稳妥的三步:(1) 先在输出端做兼容,让页面立刻正常,争取时间;(2) 抽样统计,确认这批数据的转义层数是否一致,不一致就不能统一处理;(3) 按时间段或来源分批,用 &amp;amp; 这类明确的双层特征做筛选条件,而不是无差别地对全表解一次。改完一定要过一遍安全校验,不要假设解码是个无害操作。

🏷️ 打开 HTML 实体编解码 命名/十进制/十六进制三种实体·最小转义与全量转义·双向实时·本地处理

🔗 相关阅读

全部教程 →