HTML 实体编码:防止 XSS 的第一道防线
什么是 HTML 实体编码?
简单说,HTML 实体编码就是把那些在 HTML 里有特殊含义的字符,换成浏览器不会误解析的替代写法。
你写前端的时候肯定遇到过这种情况:想在页面上显示一段代码片段 <div>Hello</div>,结果浏览器把它当成了真正的 HTML 标签渲染了,页面上直接冒出个 div 来。这不是浏览器的 bug——< 和 > 在 HTML 里天生就是标签的边界标记。要让它老老实实显示成文本,就得用实体编码:<div>Hello</div>。
说白了,HTML 实体编码就是给特殊字符贴一张”我是普通文本,别解析我”的标签。
这东西的历史比 Web 还老。实体引用(entity reference)的概念来自 SGML——HTML 的祖先标准,1986 年就定下来了。三十多年过去了,浏览器换了一代又一代,但这个 &...; 的转义格式几乎没变过。正是因为它太基础、太稳定,很多开发者反而忽略了它背后的机制。
HTML 实体编码的工作原理
实体编码的格式非常简单:以 & 开头,以 ; 结尾,中间是实体名称或编号。
三种写法
同一个字符有三种写法:
&名字; ← 命名实体,如 <
&#十进制; ← 十进制数字实体,如 <
&#x十六进制; ← 十六进制数字实体,如 <
三种写法都表示 < 这个字符,效果完全一样。命名实体好记,数字实体覆盖面广——Unicode 的所有字符都能用数字实体表示,但只有大约两千多个常用字符有命名实体。
关键字符对照表
| 字符 | 名称 | 命名实体 | 数字实体 | 含义 |
|---|---|---|---|---|
< | lt (less than) | < | < | 小于号 / 标签开始 |
> | gt (greater than) | > | > | 大于号 / 标签结束 |
& | amp (ampersand) | & | & | 实体引用的起始符 |
" | quot (quotation) | " | " | 双引号 / 属性值边界 |
' | apos (apostrophe) | ' | ' | 单引号(HTML5 新增命名实体) |
| nbsp (non-breaking space) | |   | 不换行空格 |
特别注意 &——& 本身是实体编码的起始符号,如果你要在 HTML 里显示一个 & 字符,不编码的话浏览器会以为你要开启一个新的实体引用,轻则显示错误,重则整个页面排版乱掉。
浏览器解码过程
浏览器解析 HTML 时,解码实体有一套严格的优先级:
- 先扫描
&,标记为”可能的实体开始” - 向后读,直到遇到
;,中间这段就是实体内容 - 匹配命名实体表 → 命中则替换为对应字符
- 如果是
#开头 → 按十进制或十六进制解析 Unicode 码点 - 匹配不到?回退,把
&...当普通文本显示
我之前在做一个富文本编辑器项目时就栽在这一步上——用户输入了 &lt; 这样的字符串,我拿正则一顿替换,结果二次编码变成了 &amp;lt;,页面上直接显示出了一堆乱码。教训是:实体编码和解码必须严格配对,一次编码对应一次解码,绝不能反复编解码。
核心特性
| 特性 | 说明 |
|---|---|
| 转义范围 | 最少 4 个(< > & "),最保险 5 个(加上 ') |
| 命名实体数量 | HTML5 定义了约 2200 个命名实体 |
| 数字实体覆盖 | 全部 Unicode 字符(0 到 0x10FFFF) |
| 大小写敏感 | 命名实体区分大小写,< 无效 |
| 上下文相关 | HTML 内容、属性值、JavaScript、CSS 各有不同的转义规则 |
| 不可逆损失 | 编码和解码完全可逆,无信息丢失 |
有个细节值得一说:数字实体中的十六进制写法对大小写不敏感——< 和 < 等价。但命名实体不行,< 能认,< 就直接当普通字符串显示了。这算是一个新手高频掉坑点。
实际应用场景
1. XSS 防御的第一道墙
跨站脚本攻击(XSS)的核心手段就是在页面里注入 <script> 标签或事件处理器。如果你把用户输入原样拼进 HTML,等于给了攻击者一把钥匙。
正确的做法是——输出到 HTML 内容之前做实体编码:
function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return str.replace(/[&<>"']/g, c => map[c]);
}
<script>alert('xss')</script> 被编码之后变成 <script>alert('xss')</script>——浏览器看到这个只会原样显示文本,不会去执行脚本。不过话说回来,光靠实体编码防 XSS 是不够的。输出到不同上下文(JavaScript 代码块、CSS、URL)需要不同的转义策略,这是另一个坑了。
2. 富文本编辑器的内容清洗
富文本编辑器里,用户输入的是 HTML 片段,但你不能让任意标签生效。像 <script> 和 <iframe> 这些必须干掉。
常见的策略是:先白名单过滤标签,再把属性值里的特殊字符实体编码。我之前踩过一个坑——onmouseover 这类事件属性藏得很深,单纯做标签过滤根本挡不住,必须同时对属性值做实体转义,双管齐下。
3. 代码高亮与文档展示
所有代码高亮库(Prism、highlight.js、Shiki)的底层都有一道 HTML 实体编码。代码里的 <、>、& 必须先编码才能安全地嵌入到 <pre><code> 块里,否则 <template> 这样的标签会把页面结构搞乱。
4. 邮件 HTML 模板
邮件客户端对 HTML 的解析普遍比浏览器严格得多。你会发现同样的 HTML 在浏览器里跑得好好好的,塞到邮件里就挂了。很多时候问题就出在没有严格编码特殊字符——特别是 & 符号,在邮件模板参数的拼接位置尤其容易闯祸。我用 SendGrid 做邮件模板时养成了一个习惯:所有变量输出到 HTML 位置前,一律先跑一遍实体编码函数,这比事后排错省心多了。
5. XML 和 RSS
XML 规范只预定义了 5 个实体:< > & " '。如果你在 XML 文档里用 ,解析器会直接报错——XML 不认识 HTML 的那些命名实体,除非你在 DTD 里显式声明了。这点在处理 RSS Feed 生成时特别要注意,RSS 本质上是 XML,不能用 HTML 的实体集合。
常见误区
误区一:编码一遍就万事大吉
这是我见过最多的坑。不是说编码一次不对,而是很多人搞不清楚什么时候该编码、什么时候不该。数据从数据库到后端模板、再到前端渲染、最后到 DOM 更新,中间可能经过四五层。每跨一层边界,实体编码都要重新审视。最常见的问题是”双重编码”——数据进来编一次,存到数据库又编一次,输出时模板引擎再编一次,最后页面上全是 &lt; 这样的乱码。
说实话,我在一个 Node.js 项目里排查这种问题时,打了几十个 console.log 才定位到是 Handlebars 模板自动转义和手动 escapeHtml 干了两遍同样的事。后来统一把模板引擎的自动转义关了,全部在后端手工编码,才彻底解决了。
误区二:实体编码等于安全编码
实体编码只保护 HTML 内容上下文。如果你的用户数据要被填入 <a href="...">、<script>...</script>、<style>...</style> 或者 onclick="..." 里,单独的 HTML 实体编码远远不够。这些位置有自己的语法和转义规则。比如在 JavaScript 字符串里,你需要的是 \x3C 而不是 <。
误区三:所有实体浏览器都认识
HTML5 定义的 2200 多个命名实体,主流浏览器全支持。但如果你去翻一个老旧的邮件客户端,或者某些嵌入式设备的浏览器,≥(≥)它可能就不认识。保险起见,用数字实体 ≥ 永远比命名实体更可靠——数字实体不依赖命名表,只依赖 Unicode 码点映射。
误区四:前端框架不需要关心实体编码
React 的 JSX 确实会把 {userInput} 自动转义,Vue 的 {{ }} 也一样。但如果你用了 dangerouslySetInnerHTML 或者 Vue 的 v-html,框架就把手放开了——你塞什么它渲染什么。这些 API 不是不能用,而是用之前你必须确认内容是可信的、已经清洗过的,否则就是把安全门自己拆了。
实体编码 vs URL 编码 vs Unicode 转义
| HTML 实体编码 | URL 编码 | Unicode 转义 | |
|---|---|---|---|
| 目标上下文 | HTML 文档内容 | URL 查询参数/路径 | 字符串字面量(JS/CSS) |
| 格式 | &name; &#N; | %XX(百分号+十六进制) | \uXXXX \UXXXXXXXX |
| 转义范围 | < > & " ' 等 | 非 ASCII + 保留字符 | 所有 Unicode 字符 |
| 可读性 | 较高(命名实体直观) | 低 | 低 |
| 典型场景 | XSS 防御、代码展示 | GET 参数、路径片段 | JS/CSS 源码 |
三种编码解决的是三种不同的问题。把 HTML 实体编码用在 URL 参数里是没用的——< 里的 & 会被 URL 解析器当成参数分隔符。反过来,把 %3C 直接塞到 HTML 里,浏览器也只会显示一个百分号加数字,不会当标签解析。
我之前做过一个搜索页面,需要把用户的搜索词既显示在页面上(用 HTML 实体编码)又回传到搜索框的 URL 参数里(用 URL 编码),还得在 JavaScript 里处理(用 Unicode 转义)。同一个数据三段不同的编码方式,头一次写的时候完全搞混了。
常见问题
Q: 实体编码最少要转义哪几个字符?
严格来说,最少转义 <、>、&、" 四个。但如果你在单引号包裹的属性值里输出变量,' 也得转。保守方案永远是一口气把 & < > " ' 五个全转义掉,四个和五个之间的性能差距小到可以忽略,但少转一个就可能被利用。
Q: 和普通空格有什么区别?
普通空格在浏览器里可以换行折断, (non-breaking space,不换行空格)会阻止浏览器在它所在的位置断行。所以两个词之间用 连接,它们永远在同一行。这在排版中文和数字、英文混排时特别有用——比如”iPhone 15”中间用 就不会被断成两行。
Q: 为什么有时候在 HTML 里直接用 < 也不会出错?
浏览器的 HTML 解析器有强大的纠错机制。大部分情况下,如果你写的是 2 < 3 而不是 <script>,浏览器能根据上下文判断这个 < 不是标签开始符。但这是在依赖浏览器的容错能力——你用 W3C 验证器跑一遍保证报一堆错。生产代码不要赌浏览器的纠错逻辑,老老实实写 <。
Q: HTML 实体编码和 XSS 防御是什么关系?
实体编码是 XSS 防御体系里最基础的一环,但不是全部。完整的 XSS 防御至少包括:实体编码(HTML 上下文)+ 属性加引号并编码(属性上下文)+ JavaScript 用 \x 转义(JS 上下文)+ CSS 用 \ 转义(CSS 上下文)+ URL 用百分号编码(URL 上下文)。说白了,就是”到什么山上唱什么歌”。
Q: Markdown 里需要用 HTML 实体编码吗?
Markdown 本身允许内嵌 HTML,如果你在 .md 文件里写 <div>,大部分 Markdown 解析器会原样输出给浏览器,浏览器就把它当标签渲染了。所以如果你在 Markdown 里展示 HTML 代码片段,推荐用反引号包裹(`<div>`),解析器会把代码块内容自动做实体编码。如果直接在正文里写,手动用 <div> 会更保险。