HTML 转义怎么做才不出 XSS 漏洞
开发与 Web发布于 2026-10-31
用户提交了一段 <script>alert(1)</script>,页面把它原样输出,脚本就执行了。这是 XSS 的最小模型,防护办法看起来也简单:把尖括号换掉。
但真正写起来,坑全在「换掉之后放在哪」上。
五个字符,不是两个
| 字符 | 实体 | 必须转义的原因 |
|---|---|---|
& |
& |
实体本身的起始符,不转义会把 < 当成实体解析 |
< |
< |
标签起始 |
> |
> |
标签结束(严格说非必需,但惯例都转) |
" |
" |
属性值用双引号包裹时,可以提前闭合属性 |
' |
' |
属性值用单引号包裹时同理 |
只转 < 和 > 是常见的不完整实现。属性上下文里 " 才是真正的杀手:
<input value="USER_INPUT">
用户输入 " onfocus="alert(1) 之后:
<input value="" onfocus="alert(1)">
一个合法的 onfocus 事件就注入了。没有出现一个尖括号。
& 不转义的问题更隐蔽:用户输入 <script>,浏览器会把它解码成 <script> 再当文本显示 —— 看着没问题,但如果这段内容后面又被拼进另一层解析(比如二次渲染),就会变成真的标签。
上下文决定规则
同一段用户输入,放在四个位置,需要四种不同的处理:
| 上下文 | 例子 | 处理 |
|---|---|---|
| HTML 文本 | <p>HERE</p> |
转义五个字符 |
| HTML 属性 | <div title="HERE"> |
转义五个字符,且属性值必须加引号 |
| JS 字符串 | var x = 'HERE'; |
不能用 HTML 转义,要按 JS 规则转义(\x3c 之类),更稳的做法是 JSON.stringify 后放进 data 属性 |
| URL 参数 | <a href="/s?q=HERE"> |
先 URL 编码,再放进 HTML 属性 |
最容易出事的组合是「HTML 属性里放 JS」:
<a href="javascript:void(0)" onclick="go('USER_INPUT')">
这里 ' 既不是 HTML 意义上的危险字符(在双引号属性里),也不是 JS 意义上的安全字符 —— 用户输入 ');alert(1);// 就能跳出字符串。现在这条路已经被 CSP 大幅限制,但老代码里还大量存在。
现代做法:不要拼内联事件,把数据放在 data-* 属性里(HTML 转义一次),JS 里用 dataset 读。
转义不是过滤
这两个概念经常被混为一谈:
| 转义(encoding) | 过滤(sanitizing) | |
|---|---|---|
| 目的 | 让数据在某个上下文里不被当成代码 | 从数据里删掉危险的标签和属性 |
| 是否改数据 | 不改语义,只在输出时变形 | 改,删掉的部分永久丢失 |
| 用在哪 | 纯文本输入(昵称、标题、评论) | 富文本(允许加粗、链接、图片的编辑器) |
存的时候转义是错的。 把 < 转成 < 再入库,意味着这份数据以后只能用在 HTML 里:导出成 CSV 会带一堆 <,发到 API 里对方拿到的是实体而不是原文,长度也算错了。
正确做法是原样存储,在输出的那一刻按上下文编码。
富文本只能白名单
如果业务要求「用户能加粗、能插图」,转义就走不通了 —— 转义之后 <b> 也变成文本。
这时必须做白名单过滤:只允许特定标签和特定属性,其余全部丢弃。
// 用成熟的库,别自己写正则
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt'],
});
三个必须知道的细节:
- 正则过滤 HTML 是做不到的。HTML 不是正则语言,
<scr<script>ipt>这类嵌套能绕过绝大多数手写规则。 href要单独校验协议。javascript:和data:text/html都能执行脚本,白名单里允许href就得校验它以http://、https://、/或#开头。- 过滤要在服务端做一次。前端过滤只是体验优化,攻击者可以直接调接口。
反过来,如果只是想把一段带标签的文本变成纯文本(比如把网页内容拷进邮件),用 HTML 清理去标签 就够了 —— 它剥标签、保留文字,适合做数据清洗而不是安全防护。
三个真实世界的坑
一、模板引擎默认转义,但 {{{ }}} 不转
Handlebars 的 {{ }} 会转义,{{{ }}} 不会;Vue 的 {{ }} 会转义,v-html 不会;React 的 {} 会转义,dangerouslySetInnerHTML 不会。
每套框架都留了一个「逃生门」,而事故几乎都发生在逃生门里。用之前先问:这段 HTML 是我生成的,还是用户给的?
二、二次转义显示成 &lt;
用户看到页面上写着 < 而不是 <,说明数据被编码了两次(或者存的时候编了一次,输出时又编了一次)。
判断方法:查看页面源码,如果源码里是 &lt;,就是双编。修法是找到第一次错误编码的位置删掉它,而不是在展示层再解一次。
三、JSON 接口不等于安全
把用户输入塞进 JSON 返回,看着不会有 XSS —— 但如果这个 JSON 被 <script> 标签内联进页面(常见于 SSR 的数据注水),里面的 </script> 会提前闭合标签。
这类场景要做的是 JSON 转义:< 写成 \u003c。格式化和检查 JSON 结构用 JSON 格式化与查看,能一眼看出有没有被转义坏掉的字段。
一条可以背下来的规则
输出的那一刻,按它将要去的上下文编码。 数据在库里永远是原文。
配套的三条纪律:
- 纯文本输入 → 输出时 HTML 转义(五个字符)
- 富文本输入 → 服务端白名单过滤,
href校验协议 - 属性值永远加引号,永远不要拼内联事件
想清这三条,绝大多数 XSS 报告里的复现步骤就走不通了。