清理不受信任的 HTML

Web 功能: Sanitizer API

安全地显示用户生成的 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 政策名称。