信頼できない HTML のサニタイズ

ウェブ機能: Sanitizer API

ユーザーが生成した HTML を安全に表示することは、一般的なセキュリティ上の課題です。ネイティブの Sanitizer API は、危険なコンテンツ(<script> タグや on* 属性など)が DOM に到達する前に削除する、ブラウザに最適化された組み込みの方法を提供します。

クライアントサイドのサニタイズは、コンテンツをレンダリングするページを保護します。同じユーザー作成コンテンツが他の場所(他のクライアント、サーバーでレンダリングされたページ、メールなど)でもレンダリングされる場合は、多層防御としてサーバーでもサニタイズし、サーバーとクライアントのサニタイズ ルールが競合しないようにします。

1. setHTML() を使用して HTML を安全に挿入する

Sanitizer API を使用する主な方法は、任意の Element で setHTML() メソッドを使用することです。このメソッドは、解析、サニタイズ、挿入を 1 つのステップで処理し、コンテンツが挿入されるコンテキストに合わせてサニタイズされるようにします。

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 にない要素は、そのコンテンツとともに削除されます。テキストを保持するラッパー要素(上記の div など)を replaceWithChildrenElements にリストします。これにより、要素は削除されますが、その子要素は保持されます。

XSS に対して安全でない要素や属性を許可する、より寛容な Sanitizer が必要な場合は、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 に相当する組み込みオプションがないため、一致するフォールバックが必要な構成では、このオプションを避けてください。

ページで Trusted Types(require-trusted-types-for 'script')が適用されている場合、innerHTML に文字列を割り当てるとスローされます。setHTML() には Trusted Types ポリシーは必要ありませんが、フォールバックには必要です。RETURN_TRUSTED_TYPE: true を DOMPurify.sanitize() に渡し、trusted-types CSP ディレクティブで dompurify ポリシー名を許可します。