约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
把 & < > 引号 这一组字符全替换成实体的做法本身没错,它只在一种上下文里成立:内容进入 HTML 正文。而同一个字符串如果出现在属性值里、出现在 <script> 块里、出现在 href="javascript:..." 里,需要转义的字符集合完全不同——这就是为什么「我明明转义了还是中招」。真正的修法是让危险的位置根本不解析 HTML,而不是在字符串上打补丁。
| 上下文 | 危险写法 | 正确做法 |
|---|---|---|
| HTML 正文(div 里显示用户名) | el.innerHTML = name | el.textContent = name |
| 属性值(title、placeholder) | `<div title="${name}">` 未转义引号 | 用 setAttribute,或先转义 " ' & < > |
| 未加引号的属性 | <div class=PREVIEW> | 所有属性值一律加双引号 |
| 事件处理器属性 | `<div onclick="go('${id}')">` | 不要拼事件字符串,用 addEventListener + data-* |
| JavaScript 字符串 | `<script>var a = "${name}"</script>` | 改用 application/json 或 data-* 属性传值 |
| URL 参数 | `<a href="${u}">` | 协议白名单:只允许 http/https 或相对路径 |
| 内联 SVG / 事件属性 | `<svg onload=alert(1)>` | 剥离所有 on* 属性,或用专门的 SVG 白名单清洗 |
| 富文本正文 | 直接渲染编辑器输出 | 标签白名单清洗后再插入 |
name = `"><img src=x onerror=alert(1)>`
el.innerHTML = `<p>${name}</p>`; // ① 正文:标签直接生效
el.outerHTML = `<div title="${name}">`; // ② 属性:引号被闭合,事件属性成立
el.innerHTML = `<script>var n = "${name}";</script>`; // ③ 脚本:<\/script> 就能跳出
el.innerHTML = `<a href="${url}">go</a>`; // ④ URL:javascript: 无需尖括号// ① 正文:走 textContent,永不解析 HTML
el.textContent = name;
// ② 属性:用 API 赋值,浏览器自动做上下文正确的编码
el.setAttribute("title", name);
// ③ 脚本:值不落在脚本里,落在 data-* 上由 JS 读取
el.dataset.name = name; // <div data-name="...">
// ④ URL:协议白名单,先判 scheme 再赋 href
const safe = /^https?:\/\//i.test(url) || url.startsWith("/") ? url : "#";
a.setAttribute("href", safe);手写的转义函数几乎都栽在同一类地方:字符集不全。真实的 XSS 载荷不只用尖括号——用 ' 绕过单引号、用 &#x6a; 绕过多层解码、用大小写和混合编码绕过大小写敏感的比较、用不带引号的属性绕过引号转义。补全字符集之后还有第二个问题:多重解码。值在 URL 里被解码一次、在 JS 里又被 JSON.parse 一次、在模板引擎里再反转义一次,逐层还原后精心构造的字符又活了过来。结论是:转义只适合在「单一、明确、只有一个出口」的位置做,其余位置靠结构解决。
function escapeHtml(raw) {
return String(raw)
.replace(/&/g, "&") // & 必须第一个,否则双重转义
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/\u0027/g, "'");
}
// 属性值必须加引号,转义才有意义
el.setAttribute("title", escapeHtml(raw));
// 或模板里:<div title="${escapeHtml(raw)}"></div>// ① 首选:JSON 放进不可执行的类型,</script> 无法闭合它
<script type="application/json" id="cfg">
{"user": "张\"三</script><img src=x onerror=alert(1)>"}
</script>
// ② 次选:转义脚本上下文里的三个危险字符
function escapeJs(s) {
return String(s)
.replace(/\\/g, "\\\\") // 反斜杠最先处理
.replace(/"/g, "\\\"")
.replace(/</g, "\\u003C") // 关键:不让 </script> 提前闭合
.replace(/>/g, "\\u003E")
.replace(/&/g, "\\u0026");
}评论、笔记、富文本编辑器的内容不能整体转义——转义后连加粗和换行都没了。这里的正确做法是标签白名单:允许 p、b、i、a、ul、ol 这类排版标签,其余全部拆掉;属性也白名单,a 只留 href 和 title,href 还要过协议校验;所有 on* 事件属性无条件删除。成熟方案是 DOMPurify,它把这两件事都做了。SVG 要单独配置,因为它天然带 script、foreignObject、animate 这些能执行代码的标签,不能当普通图片标签放行。
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p","br","b","i","em","strong","a","ul","ol","li","code","pre"],
ALLOWED_ATTR: ["href","title"],
FORBID_ATTR: ["style"],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/)/i
});
// 严格模式下默认剥掉 script、on*、javascript: —— 不要再自己 replaceel.innerHTML = html .replace(/<script.*?>.*?<\/script>/gis, "") // 大小写绕过、嵌套绕过、img onerror 绕过 .replace(/on\w+="[^"]*"/gi, "") // 单引号属性、属性值含反引号都能绕 .replace(/javascript:/gi, ""); // 大小写、实体、Tab 分割都能绕 // 结果:看起来干净了,实际可绕过;还会把正文里的字面文本一起删掉
内容安全策略的价值在于「就算有一处漏了,脚本也跑不起来」。最小可用配置:默认只允许同源,禁止内联脚本与 eval,然后把 CDN 域名单独列出。inline 脚本要保留时,改成 nonce 或 hash 授权(每个响应生成随机 nonce,放在响应头并写进 script 标签的 nonce 属性),不要图省事用 unsafe-inline——它会让整条策略失去意义。strict-dynamic 能让通过 nonce 加载的脚本继续 import 其它脚本,比逐个列 CDN 域更省事。
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{随机串}' https://cdn.example.com;
style-src 'self' 'unsafe-inline'; /* 样式影响面小,暂可放宽 */
img-src 'self' data: blob:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
/* 报告违规,便于逐步收紧而不打扰用户 */
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportinnerHTML 直接写入,最常见入口
outerHTML 同上,还替换了整个节点
insertAdjacentHTML 与 innerHTML 同风险
document.write 页面已有内容会被整体重写
eval / new Function / setTimeout("字符串") 可执行字符串
e.literal = jsString 事件属性同样能执行
// 逐个确认:这些位置的入参来自用户吗?是 → 换成 textContent / setAttribute按优先级三件:① 消灭 innerHTML 类的 HTML 注入入口,用户内容走 textContent 和 setAttribute;② 必须渲染 HTML 时用 DOMPurify 做标签与协议白名单;③ 补一条最小 CSP(default-src 'self'、禁 unsafe-inline、script 用 nonce),让漏网之鱼跑不起来。只做转义、或只加 CSP,都不够。
因为注入点不在 HTML 正文。属性值里需要同时转义引号, 跳出,URL 里要拦 javascript: 协议。另外多层解码(URL 解码一次、JSON 解析一次)会让精心构造的字符还原。先判断注入点在哪个上下文,再决定转义哪几个字符。
需要,但成本低很多。框架默认对插值做转义,{user.name} 和 {{ user.name }} 是安全的。真正的风险集中在四个「逃生舱」:dangerouslySetInnerHTML、v-html、直接操作 DOM、以及把外部 HTML 塞进 iframe srcdoc。另外富文本编辑器、Markdown 渲染器和导入的第三方内容不在框架保护范围内,要自己过一遍白名单。
它会让整条策略基本失效——unsafe-inline 允许任何内联脚本,绕过一次 XSS 就等于没有 CSP。正确做法是 script-src 配 nonce 或 hash:每个响应生成随机 nonce,同时放进 Content-Security-Policy 头和 script 标签的 nonce 属性,浏览器只执行带匹配 nonce 的脚本。存量代码多的话,先用 Report-Only 跑一周看违规面,再逐个改。