清理不受信任的 HTML

網頁功能: 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 不安全的元素或屬性,可以使用自訂 Sanitizer 搭配 Element.setHTMLUnsafe() 和 Document.parseHTMLUnsafe()。

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() 不需要 Trusted Types 政策,但備用方案需要:將 RETURN_TRUSTED_TYPE: true 傳遞至 DOMPurify.sanitize(),並在 trusted-types CSP 指令中允許其 dompurify 政策名稱。