安全地显示用户生成的 HTML 是一项常见的安全挑战。原生 Sanitizer API 提供了一种内置且经过浏览器优化的方法,可在危险内容(例如 <script> 标记或 on* 属性)到达 DOM 之前将其剥离。
客户端清理可保护呈现内容的页面。如果同一用户生成的内容也会在其他位置(其他客户端、服务器渲染的网页或电子邮件)渲染,请在服务器上对其进行清理,并进行深度防御,确保服务器和客户端清理规则不冲突。
1. 使用 setHTML() 安全地插入 HTML
使用 Sanitizer API 的主要方式是通过任何 Element 上的 setHTML() 方法。此方法可在一个步骤中处理解析、清理和插入,确保专门针对要插入内容的上下文进行清理。
const untrustedHTML = `
<p>Hello!</p>
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<div onclick="doBadThing()">Click me</div>
`;
const container = document.getElementById('output');
// Sanitize and insert in one call
// The default sanitizer removes scripts and event handlers, plus other
// elements and attributes outside its built-in allowlist (such as style)
container.setHTML(untrustedHTML);
2. 使用自定义排查工具配置
您可以通过创建 Sanitizer 实例来自定义清理规则。这样,您就可以针对特定用例精确定义允许使用的元素和属性。
使用 Element.setHTML() 和 Document.parseHTML() 时,默认的 Sanitizer 设置会移除所有 XSS 不安全元素和属性。您无法制定更宽松的 Sanitizer 来允许这些元素,但可以进一步限制允许的内容。
// Define a restrictive configuration that only allows the specified elements and attributes.
const config = {
elements: ["p", "b", "i", "strong", "em"],
attributes: ["class"],
replaceWithChildrenElements: ['div']
};
const mySanitizer = new Sanitizer(config);
// Apply custom sanitization
container.setHTML(untrustedHTML, { sanitizer: mySanitizer });
不在 elements 中的元素会连同其内容一起被移除。在 replaceWithChildrenElements 中列出您要保留其文本的封装容器元素(例如上面的 div),这会移除该元素,但保留其子元素。
如果您需要更宽松的 Sanitizer,允许一些 XSS 不安全的元素或属性,则可以使用带有 Element.setHTMLUnsafe() 和 Document.parseHTMLUnsafe() 的自定义 Sanitizer。
3. 使用 Document.parseHTML() 解析用户输入
如果您需要解析不受信任的 HTML,但又不想立即将其插入到实时 DOM 中,请使用 Document.parseHTML()。此方法会返回一个已根据默认或自定义规则进行排查的 Document,以便您在插入这些节点之前检查或转换它们(例如,构建预览或提取文本)。
将生成的节点移到页面中,而不是将其序列化为字符串并使用 innerHTML 重新解析,因为这可能会重新引入突变 XSS (mXSS)。
const rawHTML = '<p>Hello <script>console.log("bad")</script></p>';
const doc = Document.parseHTML(rawHTML);
// doc.body now contains the sanitized nodes: <p>Hello </p>
preview.replaceChildren(...doc.body.childNodes);
Sanitizer API 的后备方案和浏览器支持
如果目标浏览器不支持原生 Sanitizer API,请仅在缺少原生支持时加载 DOMPurify 等库,以避免将其发送到不需要它的浏览器。使用 in 检查进行功能检测,以便检查本身在旧版浏览器中也能正常运行。
let domPurifyPromise;
function loadDOMPurify() {
// Code-split with your bundler so DOMPurify is only downloaded when needed
domPurifyPromise ??= import('dompurify').then((mod) => mod.default ?? mod);
return domPurifyPromise;
}
export async function safeSetHTML(el, html) {
const elements = ['p', 'b', 'i', 'strong', 'em'];
const attributes = ['class'];
if ('setHTML' in Element.prototype) {
// setHTML() accepts a plain configuration object as the sanitizer
el.setHTML(html, { sanitizer: { elements, attributes } });
} else {
const DOMPurify = await loadDOMPurify();
el.innerHTML = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [...elements, '#text'],
ALLOWED_ATTR: attributes,
// Match the native Sanitizer: strip aria-*/data-* and disallowed element content
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: false
});
}
}
默认情况下,DOMPurify 会保留 aria-* 和 data-* 属性(即使指定了 ALLOWED_ATTR),以及它移除的元素的文本内容,而原生 Sanitizer 会剥离 aria-* 和 data-* 属性,并移除不允许的元素及其内容。设置 ALLOW_ARIA_ATTR: false、ALLOW_DATA_ATTR: false、KEEP_CONTENT: false 并允许 #text 可使 DOMPurify 与原生行为保持一致。DOMPurify 没有与 replaceWithChildrenElements 等效的内置选项,因此在需要匹配后备方案的配置中,请避免使用该选项。
如果页面强制执行可信类型 (require-trusted-types-for 'script'),则为 innerHTML 分配字符串会抛出异常。setHTML() 不需要可信类型政策,但后备需要:将 RETURN_TRUSTED_TYPE: true 传递给 DOMPurify.sanitize(),并在 trusted-types CSP 指令中允许其 dompurify 政策名称。