HTML 转义怎么做才不出 XSS 漏洞

开发与 Web发布于 2026-10-31

用户提交了一段 <script>alert(1)</script>,页面把它原样输出,脚本就执行了。这是 XSS 的最小模型,防护办法看起来也简单:把尖括号换掉。

但真正写起来,坑全在「换掉之后放在哪」上。

五个字符,不是两个

字符 实体 必须转义的原因
& &amp; 实体本身的起始符,不转义会把 &lt; 当成实体解析
< &lt; 标签起始
> &gt; 标签结束(严格说非必需,但惯例都转)
" &quot; 属性值用双引号包裹时,可以提前闭合属性
' &#39; 属性值用单引号包裹时同理

只转 < 和 > 是常见的不完整实现。属性上下文里 " 才是真正的杀手:

<input value="USER_INPUT">

用户输入 " onfocus="alert(1) 之后:

<input value="" onfocus="alert(1)">

一个合法的 onfocus 事件就注入了。没有出现一个尖括号。

& 不转义的问题更隐蔽:用户输入 &lt;script&gt;,浏览器会把它解码成 <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)
目的 让数据在某个上下文里不被当成代码 从数据里删掉危险的标签和属性
是否改数据 不改语义,只在输出时变形 改,删掉的部分永久丢失
用在哪 纯文本输入(昵称、标题、评论) 富文本(允许加粗、链接、图片的编辑器)

存的时候转义是错的。 把 < 转成 &lt; 再入库,意味着这份数据以后只能用在 HTML 里:导出成 CSV 会带一堆 &lt;,发到 API 里对方拿到的是实体而不是原文,长度也算错了。

正确做法是原样存储,在输出的那一刻按上下文编码。

富文本只能白名单

如果业务要求「用户能加粗、能插图」,转义就走不通了 —— 转义之后 <b> 也变成文本。

这时必须做白名单过滤:只允许特定标签和特定属性,其余全部丢弃。

// 用成熟的库,别自己写正则
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, {
  ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br', 'img'],
  ALLOWED_ATTR: ['href', 'src', 'alt'],
});

三个必须知道的细节:

  1. 正则过滤 HTML 是做不到的。HTML 不是正则语言,<scr<script>ipt> 这类嵌套能绕过绝大多数手写规则。
  2. href 要单独校验协议。javascript: 和 data:text/html 都能执行脚本,白名单里允许 href 就得校验它以 http://、https://、/ 或 # 开头。
  3. 过滤要在服务端做一次。前端过滤只是体验优化,攻击者可以直接调接口。

反过来,如果只是想把一段带标签的文本变成纯文本(比如把网页内容拷进邮件),用 HTML 清理去标签 就够了 —— 它剥标签、保留文字,适合做数据清洗而不是安全防护。

三个真实世界的坑

一、模板引擎默认转义,但 {{{ }}} 不转

Handlebars 的 {{ }} 会转义,{{{ }}} 不会;Vue 的 {{ }} 会转义,v-html 不会;React 的 {} 会转义,dangerouslySetInnerHTML 不会。

每套框架都留了一个「逃生门」,而事故几乎都发生在逃生门里。用之前先问:这段 HTML 是我生成的,还是用户给的?

二、二次转义显示成 &amp;lt;

用户看到页面上写着 &lt; 而不是 <,说明数据被编码了两次(或者存的时候编了一次,输出时又编了一次)。

判断方法:查看页面源码,如果源码里是 &amp;lt;,就是双编。修法是找到第一次错误编码的位置删掉它,而不是在展示层再解一次。

三、JSON 接口不等于安全

把用户输入塞进 JSON 返回,看着不会有 XSS —— 但如果这个 JSON 被 <script> 标签内联进页面(常见于 SSR 的数据注水),里面的 </script> 会提前闭合标签。

这类场景要做的是 JSON 转义:< 写成 \u003c。格式化和检查 JSON 结构用 JSON 格式化与查看,能一眼看出有没有被转义坏掉的字段。

一条可以背下来的规则

输出的那一刻,按它将要去的上下文编码。 数据在库里永远是原文。

配套的三条纪律:

  1. 纯文本输入 → 输出时 HTML 转义(五个字符)
  2. 富文本输入 → 服务端白名单过滤,href 校验协议
  3. 属性值永远加引号,永远不要拼内联事件

想清这三条,绝大多数 XSS 报告里的复现步骤就走不通了。