安全 · 前端

HTML 实体编码不是万能解药:XSS 要按上下文转义

"用户输入都做了 HTML 转义,XSS 就防住了"——这句话只对了一半,而剩下那一半正是出事的地方。把 < > & 转成实体,只在"HTML 正文"这一种上下文里有效。同样一段输入放进 HTML 属性、放进 <script>、放进 href、放进内联样式,需要的是完全不同的转义。用错地方的转义,等于没转。

XSS(跨站脚本)的本质就一句话:把用户提供的数据,当成代码执行了。防御的核心不是"过滤坏字符",而是"在输出的那一刻,按所在的上下文把数据转义成纯数据"。先记住一个反直觉的原则:转义发生在输出时,按输出位置定规则;不是在输入时一刀切。

HTML 实体编码到底转了什么

最常见的那套转义,是把这几个在 HTML 里有特殊含义的字符替换成实体:

字符实体为什么危险
<&lt;开启标签,能注入 <script>
>&gt;闭合标签
&&amp;实体本身的起始符
"&quot;闭合双引号属性值
'&#39;闭合单引号属性值

这套规则对应的是"数据放在 HTML 标签之间的正文里"这一种场景。<div>这里</div>,把上面五个字符转义掉,用户就没法注入新标签。到此为止,它是对的。问题是大部分输出并不在正文里。

五种上下文,五套规则

1. HTML 正文 —— 实体编码(前面那套)

<div>{{ 这里 }}</div>   ← 用 HTML 实体编码

2. HTML 属性 —— 必须带引号 + 转义引号

属性值不加引号是灾难。看这个:

<!-- 危险:属性没加引号 -->
<img src=头像 alt={{name}}>
<!-- 攻击者 name 传入:x onerror=alert(1) -->
<img src=头像 alt=x onerror=alert(1)>   ← 凭空多出一个事件属性

就算做了 HTML 实体编码,onerror=alert(1) 里一个特殊字符都没有,实体编码根本拦不住。属性值一律用引号包起来,并转义对应的引号,才能锁住边界。

3. JavaScript 上下文 —— 实体编码完全无效

把数据拼进 <script> 里,是 HTML 实体编码最典型的失效点:

<script>
  var name = "{{name}}";   ← 这里 HTML 实体不会被浏览器解码
</script>
<!-- name 传入:";alert(1);// -->
  var name = "";alert(1);//";   ← 脚本被注入

<script> 内部,浏览器不解析 HTML 实体,&quot; 会原样变成字符串里的 &quot;,而真正的 " 照样闭合字符串。正确做法是别拼字符串——把数据用 JSON.stringify 序列化,或干脆通过 data-* 属性 + dataset 读取,绕开拼接。

4. URL 上下文 —— 用 URL 编码,还要防 javascript:

<a href="{{url}}">链接</a>
<!-- url 传入:javascript:alert(1) -->
<a href="javascript:alert(1)">链接</a>   ← 点一下就执行

放进 URL 的参数要用 URL 编码encodeURIComponent),但这还不够——还得校验协议,只允许 http:/https:,拒绝 javascript:data: 这类能执行代码的伪协议。

5. CSS 上下文 —— 别让用户数据进样式

把用户数据拼进内联 style<style>,能通过 expression()(老 IE)、url() 等方式触发。原则简单:尽量不让用户输入进入 CSS;非要进,只允许白名单内的值(比如校验过的颜色码、数字)。

🚫

一句话总结这五种:同一段输入,转义方式取决于它最终落在哪。全站统一用一套 HTML 实体编码,是真实世界里最普遍的 XSS 成因——它在正文里有效,给了你"已经处理过"的错觉,但属性、脚本、URL、样式那几个口子全开着。

前端最实用的两条:textContent 和别碰 innerHTML

手动转义容易漏。浏览器其实给了你天然安全的 API——textContent 会把内容当纯文本插入,自动转义:

// ❌ 危险:innerHTML 把字符串当 HTML 解析
el.innerHTML = userInput;            // <img onerror> 直接生效

// ✅ 安全:textContent 当纯文本,浏览器自动转义
el.textContent = userInput;          // 标签变成可见的文字

// 需要构建结构就用 DOM API,而不是拼 HTML 字符串
const a = document.createElement("a");
a.textContent = userName;            // 文本安全
a.href = safeUrl;                    // href 单独校验协议

能用 textContent 就不要用 innerHTML。真要插入富文本,用经过审计的库(如 DOMPurify)做净化,别自己写正则过滤——XSS 的 payload 变体多到正则永远追不上。

连 React 都能被 XSS

"我用 React,JSX 自动转义,不用管 XSS"——这是另一个常见误解。React 确实对 JSX 正文里的 {value} 自动做 HTML 转义,但有几个口子它管不到:

框架的自动转义只覆盖"正文"这一种上下文——又绕回了本文的主线。

最后:纵深防御

转义是第一道,也是最重要的一道,但别只靠它。再加两层成本很低的兜底:

要快速把一段文本转成 HTML 实体、或把实体还原看真实内容,用 在线 HTML 实体编码工具,本地计算不上传。

在线 HTML 实体编码

HTML 实体编码与解码,特殊字符一键转换,全本地计算

打开 HTML 实体工具