XSS 转义怎么做:别再手动 replace 尖括号,四个上下文各有各的规则

记不住 < 不是问题,问题是不知道自己在哪个上下文里输出。HTML 正文、属性值、JavaScript 字符串、URL 四套规则各不通用:正文用 textContent,属性要加引号再转义引号,脚本里要转义斜杠和引号,URL 要做协议白名单。附上下文对照表与一份最小可用 CSP。

约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

XSS 不是「忘记转义」,是「转义用错了地方」

把 & < > 引号 这一组字符全替换成实体的做法本身没错,它只在一种上下文里成立:内容进入 HTML 正文。而同一个字符串如果出现在属性值里、出现在 <script> 块里、出现在 href="javascript:..." 里,需要转义的字符集合完全不同——这就是为什么「我明明转义了还是中招」。真正的修法是让危险的位置根本不解析 HTML,而不是在字符串上打补丁。

四个上下文对照表:先判断注入点在哪一格

上下文危险写法正确做法
HTML 正文(div 里显示用户名)el.innerHTML = nameel.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);

为什么手动 replace 尖括号不算修复

手写的转义函数几乎都栽在同一类地方:字符集不全。真实的 XSS 载荷不只用尖括号——用 &#x27; 绕过单引号、用 &amp;#x6a; 绕过多层解码、用大小写和混合编码绕过大小写敏感的比较、用不带引号的属性绕过引号转义。补全字符集之后还有第二个问题:多重解码。值在 URL 里被解码一次、在 JS 里又被 JSON.parse 一次、在模板引擎里再反转义一次,逐层还原后精心构造的字符又活了过来。结论是:转义只适合在「单一、明确、只有一个出口」的位置做,其余位置靠结构解决。

必须手写转义时的字符集与顺序

HTML 正文 / 属性值的最小转义函数
function escapeHtml(raw) {
  return String(raw)
    .replace(/&/g, "&amp;")     // & 必须第一个,否则双重转义
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/\u0027/g, "&#39;");
}

// 属性值必须加引号,转义才有意义
el.setAttribute("title", escapeHtml(raw));
// 或模板里:<div title="${escapeHtml(raw)}"></div>
放进 <script> 时的两种安全形态
// ① 首选: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");
}

富文本和 Markdown 是例外:白名单清洗,不是转义

评论、笔记、富文本编辑器的内容不能整体转义——转义后连加粗和换行都没了。这里的正确做法是标签白名单:允许 p、b、i、a、ul、ol 这类排版标签,其余全部拆掉;属性也白名单,a 只留 href 和 title,href 还要过协议校验;所有 on* 事件属性无条件删除。成熟方案是 DOMPurify,它把这两件事都做了。SVG 要单独配置,因为它天然带 script、foreignObject、animate 这些能执行代码的标签,不能当普通图片标签放行。

✓ 富文本白名单清洗(含 URL 协议校验钩子)
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: —— 不要再自己 replace
✗ 常见的“半个过滤器”
el.innerHTML = html
  .replace(/<script.*?>.*?<\/script>/gis, "")   // 大小写绕过、嵌套绕过、img onerror 绕过
  .replace(/on\w+="[^"]*"/gi, "")             // 单引号属性、属性值含反引号都能绕
  .replace(/javascript:/gi, "");              // 大小写、实体、Tab 分割都能绕
// 结果:看起来干净了,实际可绕过;还会把正文里的字面文本一起删掉

CSP 是第二道防线,不是第一道

内容安全策略的价值在于「就算有一处漏了,脚本也跑不起来」。最小可用配置:默认只允许同源,禁止内联脚本与 eval,然后把 CDN 域名单独列出。inline 脚本要保留时,改成 nonce 或 hash 授权(每个响应生成随机 nonce,放在响应头并写进 script 标签的 nonce 属性),不要图省事用 unsafe-inline——它会让整条策略失去意义。strict-dynamic 能让通过 nonce 加载的脚本继续 import 其它脚本,比逐个列 CDN 域更省事。

最小可用 CSP 响应头
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-report
从源码里找 XSS 面:按危险 API 搜
innerHTML        直接写入,最常见入口
outerHTML        同上,还替换了整个节点
insertAdjacentHTML  与 innerHTML 同风险
document.write   页面已有内容会被整体重写
eval / new Function / setTimeout("字符串")  可执行字符串
e.literal = jsString        事件属性同样能执行

// 逐个确认:这些位置的入参来自用户吗?是 → 换成 textContent / setAttribute

上线前的六条自查

  1. 全仓搜危险 API:innerHTML、outerHTML、insertAdjacentHTML、document.write、eval、new Function、setTimeout 传字符串、on* 事件属性。逐个判定入参来源。
  2. 搜模板拼接:所有 ${ } 插进 HTML 的地方,确认插的是转义后的值还是原始值。特别注意 <a href> 和 <img src> 两类。
  3. 确认属性都加了引号:没加引号的属性能被空格直接切断,一整串 payload 都不需要尖括号。
  4. 搜 javascript: 与 data:text/html:这两种协议能执行代码,任何把用户输入拼进 href/src 的地方都要过协议白名单。
  5. 搜二次注入路径:写进数据库的原始内容,在另一个地方被拼进 SQL 或 HTML——这是存储型 XSS 与二阶注入的交叉点。
  6. 配一条最小 CSP 并先跑 Report-Only:看违规报告里还有哪些位置在用内联脚本,逐个改成 nonce,而不是直接开 unsafe-inline。

常见问题

XSS 防御到底该做哪几件事?

按优先级三件:① 消灭 innerHTML 类的 HTML 注入入口,用户内容走 textContent 和 setAttribute;② 必须渲染 HTML 时用 DOMPurify 做标签与协议白名单;③ 补一条最小 CSP(default-src 'self'、禁 unsafe-inline、script 用 nonce),让漏网之鱼跑不起来。只做转义、或只加 CSP,都不够。

为什么我已经转义了 & < >,还是被 XSS?

因为注入点不在 HTML 正文。属性值里需要同时转义引号, 跳出,URL 里要拦 javascript: 协议。另外多层解码(URL 解码一次、JSON 解析一次)会让精心构造的字符还原。先判断注入点在哪个上下文,再决定转义哪几个字符。

React / Vue 项目还需要管 XSS 吗?

需要,但成本低很多。框架默认对插值做转义,{user.name} 和 {{ user.name }} 是安全的。真正的风险集中在四个「逃生舱」:dangerouslySetInnerHTML、v-html、直接操作 DOM、以及把外部 HTML 塞进 iframe srcdoc。另外富文本编辑器、Markdown 渲染器和导入的第三方内容不在框架保护范围内,要自己过一遍白名单。

CSP 里的 unsafe-inline 能不能用?

它会让整条策略基本失效——unsafe-inline 允许任何内联脚本,绕过一次 XSS 就等于没有 CSP。正确做法是 script-src 配 nonce 或 hash:每个响应生成随机 nonce,同时放进 Content-Security-Policy 头和 script 标签的 nonce 属性,浏览器只执行带匹配 nonce 的脚本。存量代码多的话,先用 Report-Only 跑一周看违规面,再逐个改。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件