HTML 实体编码不是万能解药:XSS 要按上下文转义
"用户输入都做了 HTML 转义,XSS 就防住了"——这句话只对了一半,而剩下那一半正是出事的地方。把 < > & 转成实体,只在"HTML 正文"这一种上下文里有效。同样一段输入放进 HTML 属性、放进 <script>、放进 href、放进内联样式,需要的是完全不同的转义。用错地方的转义,等于没转。
XSS(跨站脚本)的本质就一句话:把用户提供的数据,当成代码执行了。防御的核心不是"过滤坏字符",而是"在输出的那一刻,按所在的上下文把数据转义成纯数据"。先记住一个反直觉的原则:转义发生在输出时,按输出位置定规则;不是在输入时一刀切。
HTML 实体编码到底转了什么
最常见的那套转义,是把这几个在 HTML 里有特殊含义的字符替换成实体:
| 字符 | 实体 | 为什么危险 |
|---|---|---|
< | < | 开启标签,能注入 <script> |
> | > | 闭合标签 |
& | & | 实体本身的起始符 |
" | " | 闭合双引号属性值 |
' | ' | 闭合单引号属性值 |
这套规则对应的是"数据放在 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 实体," 会原样变成字符串里的 ",而真正的 " 照样闭合字符串。正确做法是别拼字符串——把数据用 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 转义,但有几个口子它管不到:
dangerouslySetInnerHTML—— 名字里都写了 dangerously,等于手动innerHTML,不净化就是洞。<a href={userInput}>—— React 不校验协议,javascript:照样能进。- 把用户数据塞进
<script>、style、或ref回调里手动操作 DOM。
框架的自动转义只覆盖"正文"这一种上下文——又绕回了本文的主线。
最后:纵深防御
转义是第一道,也是最重要的一道,但别只靠它。再加两层成本很低的兜底:
- CSP(内容安全策略):用 HTTP 头限制脚本来源、禁掉内联脚本。就算有一处转义漏了,CSP 能拦住注入脚本的执行。
- Cookie 加
HttpOnly:让 JS 读不到会话 Cookie,即便被 XSS,也偷不走登录态(这点和 登录态怎么存相关)。
要快速把一段文本转成 HTML 实体、或把实体还原看真实内容,用 在线 HTML 实体编码工具,本地计算不上传。