☰
HTML实体转义详解:从XSS防御到跨格式转换的工程实践
2026/9/27 1:16:21 网站建设 项目流程

1. 从一个被忽视的字符说起:为什么实体转义值得单独拎出来讲

很多人第一次接触 HTML 实体转义,都是在某个莫名其妙的 bug 现场。页面上明明想显示一个数学比较符号,结果浏览器把它当成了标签的尖括号,后面的内容全乱了;或者用户提交了一段带<script>的评论,第二天发现页面弹出了奇怪的对话框。这些问题的根子,都指向同一件事:HTML 里的<和>不是普通字符,它们是标记语言的语法符号。

这篇内容想做的事情很明确:把 HTML 实体转义这件事从头到尾讲透,尤其是<(&lt;)和>(&gt;)这两个最核心的实体。我会从浏览器解析原理讲到实际编码中的转义策略,从 XSS 防御讲到 PDF、邮件、Markdown 转换这些容易翻车的场景,再配上可以直接抄的代码和排查清单。不管你是刚学 HTML 的新手,还是已经写过几年后端、正在处理富文本过滤的老手,都能从中找到对自己有用的部分。

先说结论:实体转义的本质,是在“数据”和“代码”之间划一条界线。浏览器解析 HTML 时,看到<就认为后面跟着的是标签名、属性或者注释,它不会去猜你到底是“想显示一个小于号”还是“想开一个标签”。所以只要你想让<以字符的形式出现在页面上,就必须写成&lt;;同理,>要写成&gt;。这不是可选项,而是 HTML 语法层面的硬性规定。

那为什么偏偏是这两个符号最容易被忽略?因为它们在日常文本里出现频率不高,开发者测试时往往用“hello world”这种干净字符串,一上线遇到用户输入<3或者a > b就出问题。更麻烦的是,<和>的转义缺失,恰恰是反射型 XSS、存储型 XSS、DOM 型 XSS 最常见的入口之一。热搜词里反复出现的 xss、dvwa、ctfshow、portswigger 靶场,本质上都在考同一件事:你有没有在正确的位置做正确的转义。

2. HTML 实体转义的核心原理与浏览器解析机制

2.1 浏览器如何“读”一段 HTML

要理解转义,先得理解浏览器怎么读 HTML。你可以把浏览器的 HTML 解析器想象成一个非常死板的读者,它拿到一串字符后,按顺序扫描,一旦遇到<,就进入“标签开始状态”,接下来读到的字母会被当作标签名,直到遇到>才认为标签结束。整个过程没有任何“智能判断”,它不会因为你写的是a < b就网开一面。

这就带来一个直接后果:任何未经转义的<都会改变文档结构。比如你想在页面上显示一段代码示例<div class="box">,如果直接写进 HTML,浏览器会真的创建一个 div,而不是把这段文字显示出来。要让它老老实实当文字,就得写成&lt;div class=&quot;box&quot;&gt;。注意这里连引号也转义了,因为属性值里的引号同样有语法意义。

>的情况稍微特殊一点。在大多数场景下,单独的>不会引发解析错误,浏览器能容忍它。但在某些边界情况下,比如它出现在标签内部、或者和<配合时,就可能造成歧义。所以工程上的稳妥做法是:<和>一律转义,不要心存侥幸。这也是为什么很多安全规范直接把这两个符号列为“必须转义字符”。

2.2 实体转义的三种写法与选择依据

HTML 实体有三种写法,拿小于号举例:

写法形式特点适用场景
命名实体&lt;可读性好,兼容性最佳绝大多数场景首选
十进制实体&#60;纯数字,不依赖实体名表需要规避实体名过滤时
十六进制实体&#x3C;同上,更紧凑同上

命名实体&lt;和&gt;是最常用的,因为一眼就能看懂。十进制和十六进制写法在某些安全过滤场景下会被用到,比如过滤规则只拦截了&lt;却漏了&#60;,攻击者就可能绕过。反过来,作为防御方,你在做输入过滤时,必须把三种形式都考虑进去,否则就是给自己留后门。

这里有个容易被忽略的点:实体转义是有“上下文”的。同样是用户输入,放在 HTML 文本节点里、放在属性值里、放在 JavaScript 字符串里、放在 URL 里,需要的转义规则完全不同。&lt;能解决 HTML 文本节点的问题,但如果你把用户输入直接拼进<script>var x = '...'</script>,那&lt;根本救不了你,因为 JavaScript 解析器不认 HTML 实体。这是很多 XSS 漏洞的真正成因——转义用错了地方。

2.3 实体转义与字符编码的关系

还有一个经常被混淆的概念:实体转义和字符编码(charset)不是一回事。<meta charset="utf-8">解决的是“字节怎么变成字符”,而实体转义解决的是“字符怎么变成 HTML 里的安全表示”。两者配合才能保证页面正确显示。

举个实际例子:如果你的页面声明了 UTF-8,但用户输入里包含了一个特殊字符,浏览器能正确解码它,但如果这个字符恰好是<,它依然会被当成标签开始。编码正确不代表转义正确,这是两个独立的维度。热搜里那些<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8">的片段,说明很多人在搭页面时只关注了编码声明,却忘了输出转义。

3. 从 XSS 视角看转义:为什么<和>是防御第一线

3.1 反射型、存储型、DOM 型 XSS 的共同点

XSS 漏洞的分类很多,反射型、存储型、DOM 型,靶场里 dvwa、pikachu、ctfhub、portswigger 各有各的玩法。但剥开表象,它们的共同点只有一个:用户可控的数据,未经正确处理就进入了 HTML 解析流程。

反射型 XSS 是服务端把请求参数直接回显到页面;存储型 XSS 是把用户输入存进数据库,再在别的页面渲染出来;DOM 型 XSS 则是前端 JavaScript 从 URL、localStorage 等位置读取数据,用 innerHTML 之类的方法写进 DOM。三种路径不同,但防御的核心动作高度一致:在数据进入 HTML 上下文之前,把<、>、&、"、'这些有语法意义的字符转义掉。

我拿一个最小例子说明。假设后端模板里有这么一行:

<p>搜索结果:{{ keyword }}</p>

如果keyword是用户输入的<script>alert(1)</script>,而模板引擎没有自动转义,浏览器就会执行这段脚本。如果模板引擎做了转义,页面会显示&lt;script&gt;alert(1)&lt;/script&gt;,用户看到的是文字,脚本不会执行。区别就这么简单,但后果天差地别。

3.2 转义不是“过滤”,别把两者混为一谈

这里必须澄清一个常见误区:转义和过滤是两件事。过滤是“删掉或拦截某些内容”,转义是“把内容变成安全的表示形式”。很多开发者一上来就写正则去删<script>,结果被各种变形绕过,比如大小写混写、嵌套标签、实体编码绕过。

正确的思路是:输出转义为主,输入过滤为辅。你在数据要进入 HTML 的那一刻做转义,不管它长什么样,<一律变&lt;,这样任何标签都构造不出来。过滤则用于一些确实需要限制的场景,比如只允许纯文本、不允许任何 HTML 标签时,可以在输入端做白名单校验。但过滤规则永远可能被绕过,转义才是最后一道可靠的防线。

热搜里提到的“springboot 项目全局过滤器处理上传 pdf 文件时 xss 攻击”,就是一个典型场景:PDF 文件里可能嵌入了恶意脚本,如果系统把 PDF 内容提取出来直接渲染到页面,就可能触发 XSS。这时候全局过滤器要做的事情,不是简单删字符,而是在输出环节确保所有动态内容都经过转义。

3.3 不同上下文的转义策略对照

我把常见上下文和对应的转义要求整理成一张表,方便你对照检查:

上下文危险字符处理方式
HTML 文本节点<>&转义为&lt;&gt;&amp;
HTML 属性值(双引号)"&<>转义为&quot;&amp;&lt;&gt;
HTML 属性值(单引号)'&<>转义为&#39;&amp;&lt;&gt;
JavaScript 字符串'"\换行用 JS 转义,不能用 HTML 实体
URL 参数非字母数字字符用 URL 编码(percent-encoding)
CSS 值特殊字符用 CSS 转义

这张表的核心信息是:没有一种转义能通吃所有场景。你在 HTML 文本里用&lt;是对的,但把它放进 JavaScript 字符串里就是错的。很多 XSS 漏洞不是因为开发者没转义,而是因为转义用错了上下文。

4. 实操:手写一个可靠的 HTML 转义函数

4.1 转义函数的最小实现

先看一个最基础的 JavaScript 转义函数,适用于 HTML 文本节点和双引号属性值:

function escapeHtml(str) { if (typeof str !== 'string') return ''; return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); }

这段代码有几个细节值得说。第一,&必须最先替换,否则后面替换出来的&lt;里的&会被二次转义成&amp;lt;,显示就错了。第二,单引号用&#39;而不是&apos;,因为&apos;在旧版 HTML 里支持不好。第三,函数开头做了类型判断,避免非字符串输入导致报错。

如果你在 Java 后端,可以用 Spring 自带的HtmlUtils.htmlEscape(),或者 Apache Commons Text 的StringEscapeUtils.escapeHtml4()。Python 里可以用html.escape(),注意它默认会把引号也转义。这些库都经过大量验证,比手写正则可靠,生产环境优先用库。

4.2 什么时候不该用转义

转义不是万能的,有些场景用了反而出问题。最典型的是富文本编辑器。如果用户合法地写了一段带<b>加粗的 HTML,你无脑转义,加粗效果就没了。这时候需要的是 HTML 净化(sanitize),而不是简单转义。常见做法是用 DOMPurify 这类库,按白名单保留安全标签,剔除危险标签和属性。

另一个场景是已经转义过的内容再次转义。比如数据在入库时转了一次,出库渲染时又转一次,页面上就会显示&amp;lt;这种双重转义的结果。解决办法是明确转义发生的唯一位置——通常是在输出到 HTML 的那一刻,存储时保持原始数据。

提示:转义只做一次,且只在输出到对应上下文时做。存储层保持原始数据,方便后续用于其他格式(如导出 CSV、生成 PDF)。

4.3 在模板引擎里如何正确配置

现代模板引擎大多默认开启自动转义,比如 Thymeleaf、Jinja2、Vue 的模板语法。但“默认开启”不等于“永远安全”,有几个坑要避开。

Thymeleaf 里用th:text是自动转义的,用th:utext则不转义。如果你为了显示 HTML 效果改用了th:utext,就必须确保内容已经过净化。Jinja2 里{{ }}自动转义,{% autoescape false %}会关闭,慎用。Vue 里{{ }}插值自动转义,v-html不转义,同样需要配合净化库。

我见过太多项目,开发者为了图方便到处用v-html或th:utext,结果把自动转义的保护全绕过了。默认安全,显式危险,这是用模板引擎时应该记住的原则。

5. 那些容易翻车的边界场景

5.1 HTML 转 Markdown、PDF、邮件时的转义

热搜里出现了“html 转为 md”“html 邮件”“springboot 项目全局过滤器处理上传 pdf 文件时 xss 攻击”这些词,说明跨格式转换是转义问题的高发区。

HTML 转 Markdown 时,如果原始 HTML 里有<字符,转换器可能把它当成标签解析,导致内容丢失或结构错乱。稳妥做法是先把 HTML 解析成 DOM 树,再按节点类型生成 Markdown,而不是用正则硬替换。

HTML 邮件更麻烦,因为不同邮件客户端对 HTML 的支持差异极大。有些客户端会过滤<script>,有些不会。所以邮件模板里的动态内容必须严格转义,尤其是用户填写的姓名、地址等字段。我建议邮件模板里所有变量都用转义输出,宁可多转不可漏转。

PDF 场景的坑在于:如果系统把用户上传的 PDF 内容提取成 HTML 再渲染,提取出来的文本里可能包含<等字符。这时候要在渲染前统一转义,而不是相信 PDF 提取库会帮你处理干净。

5.2 DOM 型 XSS 与 innerHTML

DOM 型 XSS 的特点是漏洞发生在浏览器端,服务端日志里看不到异常。典型写法是:

document.getElementById('output').innerHTML = location.hash.slice(1);

如果 URL 是#<img src=x onerror=alert(1)>,这段代码就会执行脚本。修复方式有两种:一是改用textContent,它不解析 HTML;二是如果确实需要渲染 HTML,先用 DOMPurify 净化。

textContent是最省心的方案,它把内容当纯文本处理,<就是<,不会触发解析。缺点是没法显示富文本。所以选择哪种方案,取决于你的业务需求,但能用 textContent 就别用 innerHTML,这是减少 DOM 型 XSS 最直接的手段。

5.3 属性值里的转义陷阱

属性值转义有个经典陷阱:未加引号的属性值。比如<a href=?q=用户输入>,如果用户输入里带空格或>,属性就会被截断,后面的内容可能被解析成新属性甚至新标签。所以属性值一定要加引号,且引号内的内容要转义。

另一个陷阱是href和src里的javascript:协议。即使你转义了<和>,如果用户输入是javascript:alert(1),点击链接依然会执行脚本。这类问题靠转义解决不了,需要做协议白名单校验,只允许http、https、mailto等安全协议。

6. 常见问题排查速查表

现象可能原因排查方向
页面显示&lt;而不是<双重转义检查是否在存储和输出都做了转义
用户输入导致页面结构错乱未转义<或>检查输出点是否用了自动转义
弹窗、跳转等异常行为XSS 注入检查所有动态输出点,尤其是 innerHTML
富文本显示成源码过度转义改用净化库而非全量转义
邮件内容错位邮件客户端解析差异简化 HTML 结构,严格转义变量
PDF 导出内容异常提取文本未转义在渲染前统一转义

排查 XSS 类问题时,我的习惯是先把所有动态输出点列出来,逐个确认上下文和转义方式。浏览器开发者工具里看最终 DOM 结构,比看源码更直观,因为 DOM 是解析后的结果,能直接暴露转义是否生效。

注意:不要依赖前端过滤来防御 XSS。前端代码可以被绕过,服务端输出转义才是可靠防线。

7. 我踩过的坑和几条实用经验

第一个坑是以为模板引擎会自动处理一切。早期做项目时,我在 Thymeleaf 里用了th:utext显示公告内容,觉得内容是自己人写的没问题。后来公告功能开放给运营人员,有人粘贴了带脚本的 HTML,直接触发了存储型 XSS。从那以后,凡是utext、v-html、innerHTML这些“危险 API”,我都会在代码审查时重点标记。

第二个坑是转义和编码混淆。有次页面中文乱码,我第一反应是去改 charset,结果发现是数据在传输过程中被错误地做了 HTML 实体编码,导致&amp;之类的字符混进了数据。这类问题的排查思路是:先确认数据在每一层的形态,是原始字符、URL 编码还是 HTML 实体,搞清楚再动手。

第三个经验是建立输出转义的检查清单。每次新增一个页面或接口,我都会问自己:这里输出的数据来自哪里?是用户可控的吗?进入的是什么上下文?有没有做对应转义?这三个问题能拦住大部分低级漏洞。对于团队协作,我会把这份清单写进代码规范,配合 ESLint 或 SonarQube 之类的静态检查工具,自动扫描innerHTML、document.write等危险调用。

最后一个实用技巧:测试时用“恶意输入”而不是“正常输入”。很多人测试表单就填“张三”“123”,当然测不出问题。我习惯在测试阶段就往输入框里塞<script>alert(1)</script>、"><img src=x onerror=alert(1)>、javascript:alert(1)这几组 payload,看页面反应。如果弹窗了,说明转义没做到位;如果原样显示成文字,说明转义生效。这个方法简单粗暴,但非常有效。

实体转义这件事,说到底就是一层窗户纸,捅破了就明白:<和>在 HTML 里有特殊含义,想让它们当普通字符,就得写成&lt;和&gt;。但真正把它用对、用全、用在不用的上下文里,需要的是对解析机制的理解和对输出点的敏感度。希望这篇内容能帮你把这层窗户纸彻底捅穿,下次再遇到转义问题,能一眼看出症结在哪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询