页面上出现了这样的东西:
张三 李四 ← 本该是一个空格,却显示成五个字符
产品 A & 产品 B ← 本该是 &
  ← 这个更离谱
三种都是同一个病:这段文本在从输入到输出的路上,被转义了不止一次。
先数一数转了几次
规律很整齐,因为转义本身是可叠加的:
| 原文 | 转 1 次 | 转 2 次 | 转 3 次 |
|---|---|---|---|
& | & | & | & |
|   |   | … |
<p> | <p> | &lt;p&gt; | … |
每多转一次,就多一层 amp;。
实操方法:把那段文本粘进 HTML 实体编解码,反复解码直到结果不再变化,解了几次就是被转了几次。
一个例外要留意:如果这段内容本来就在讲 HTML 转义(比如这篇文章本身),那它「应该」带着
&,被转成&amp;才是对的。所以多一层不等于一定是 bug,要结合数据本身是什么来判断。
五种常见的来源
数出层数之后,下一步是找出链路上有几个转义点。按出现频率排:
1. 入库前转义 + 模板引擎输出时又转
最常见的一种。后端出于「防 XSS」的想法在写库前转义了一次,而现代模板引擎(Vue、React、Thymeleaf、Jinja2 的自动转义)在输出时默认还会转一次。
两次相加,页面上就出现了字面量的实体。
2. 富文本编辑器的内容被当成普通文本
编辑器存出来的本来就是 HTML,结果被当成普通文本转义了一次,渲染时模板引擎再转一次——用户看到满屏的 <p>。
3. 接口层已经转好了,前端又转
后端返回的 JSON 里已经是 &,前端用 textContent 赋值(不解码)或者再做一次转义。
4. 多个系统接力,每一层都「保险起见转一下」
A 系统传给 B,B 传给 C,每一层的作者都不确定上游做没做,于是都做了。这种最难查,因为每一层单看都很合理。
5. 日志、导出、二次采集
从页面上抓下来的内容本身就是转义后的,再存回库里就多了一层。
转义到底该放在哪一层
一条原则可以解决上面全部五种:
存原文,在输出的那一刻按目标上下文转义一次。
英文叫 output encoding。它的对立面——在输入端转义(input sanitization)——是错的,理由有三条:
一、你不知道它将来会去哪。 同一段文本的四种去处,规则完全不同:
| 输出到 | 该用什么 |
|---|---|
| HTML 正文 | HTML 实体转义 |
<script> 里的 JS 字符串 | JS 字符串转义,HTML 实体在这里完全无效 |
| URL 参数 | URL 编码 |
| CSV / 短信 / PDF / 日志 | 一个都不要转 |
在输入端转义,等于提前替所有下游做了决定,而且做错了。
二、搜索会失灵。 用户搜「张三&李四」,库里存的是 张三&李四,匹配不上。
三、长度统计失真。 一个 & 占五个字符,字数限制、截断、摘要全乱。
富文本是另一条路
需要区分两类字段:
| 字段类型 | 将来要以 HTML 渲染吗 | 该做什么 |
|---|---|---|
| 普通文本(用户名、标题、评论) | 否 | 输出时转义 |
| 富文本(文章正文、编辑器内容) | 是 | 白名单清洗 |
富文本不能转义——转了标签就变字面量了。它要做的是 sanitize:用成熟的库按允许的标签和属性过一遍,去掉 <script>、on* 事件属性、javascript: 协议的链接。
清洗放在输出时或入库时都可以,但只做一次,并且记清楚做在哪一层。
修历史数据的那个陷阱
假设你已经有了一批二次转义的数据,想批量解码修回来。这是这一整件事里最危险的操作。
风险在于:批量解码时很难区分两种数据
- 被转了两次的 —— 该解一次
- 只转了一次、但原文本身就含实体的 —— 不该解
对后者多解一次:
库里: &lt;script&gt;
解一次:<script>
输出: <script> ← XSS 回来了
你亲手把注入放了回去。
稳妥的做法是三步:
- 先在输出端做兼容,让页面立刻正常,争取时间
- 抽样统计,确认这批数据的转义层数是否一致——不一致就不能统一处理
- 按时间段或数据来源分批,用
&amp;这类明确的双层特征做筛选条件,而不是无差别地对全表解一次
改完一定要过一遍安全校验。不要假设解码是个无害操作。
顺带:转义解决不了的三件事
即使转义时机完全正确,也还有三个它管不到的地方:
一、属性值必须有引号。 <div title=用户输入> 这种无引号属性,即使转义了五个字符,攻击者仍然可以用空格加上 onerror= 注入。
二、<script> 里要用 JS 转义。 HTML 实体在 JS 解析器眼里就是普通字符,< 不会变成 <,但也挡不住 </script> 提前闭合标签。
三、URL 要用 URL 编码。 而且 href="javascript:..." 这类协议要单独拦,编码解决不了。
一句话:实体转义解决的是「HTML 解析器怎么读这段文本」,管不到 JS、URL 和 CSS 解析器。
相关
- 各种编码在 URL 上叠加造成的类似问题 → URL 多重编码与注入防御
encodeURI和encodeURIComponent什么时候用哪个 → 两者的区别 解出来那个看不见的字符是什么 → 文本里看不见的字符- 长得一样但码点不同的字符怎么用来钓鱼 → Unicode 同形字攻击