← 記事一覧へ

Chrome 拡張で配信ページの DOM を popup に
「鏡のように貼り付ける」ホワイトリスト方式 sanitizer

配信プラットフォームの DOM(広告ランキング・イベント累計スコア・現在順位など)の outerHTML を、拡張機能の popup にそのまま「鏡のように」描画する手法。DOMPurify 等の依存を持たずに、許可タグ・許可属性のホワイトリスト + SVG namespace 維持 + defs id rename + url(#id) 同期で、XSS と画面遷移事故を同時に防ぐ。

公開日: 2026-05-11 著者: 君斗りんく / Kimito-Link Project ライセンス: MIT 実装: GitHub

1.解決したい課題

Chrome 拡張機能のポップアップに、視聴中の配信ページ(タブ)が表示している公式値レーン(広告ランキング・イベント累計スコア・現在順位・番組累計ポイント等)を、配信者が popup を開いた瞬間にそのままの見た目で並べたい。

選択肢は通常 3 つある:

  • (A) JSON で値だけ取得して拡張側で再描画: プラットフォーム本体の見た目とずれる。CSS 追従が継続的にコストになる。
  • (B) iframe で配信ページの該当区画を埋め込み: cross-origin で操作不可、scroll や Vue mount の問題で表示が壊れやすい。
  • (C) DOM の outerHTML をそのまま popup に流す: 見た目は完璧に揃うが、XSS・画面遷移事故・id 衝突のリスクが新規発生する。

本記事は (C) を選択した場合に必要な、自前ホワイトリスト sanitizerの設計を扱う。

2.手法の概要

「鏡のように貼り付ける」フロー:

  1. 配信ページ側の content script で対象 DOM(例: <span class="score">)の outerHTML を取得
  2. chrome.runtime.sendMessage で popup 側へ HTML 文字列を渡す
  3. popup 側で DOMParser によりサンドボックスで parse
  4. ホワイトリスト方式 sanitizerで許可タグ・許可属性のみ通し、SVG defs の id を nonce 付きに rename
  5. sanitize 後の DOM を popup の対応レーン枠に append

ホワイトリストの粒度:

  • 許可タグ: テキスト構造(div span p strong)・リスト・テーブル・img・SVG(svg path defs linearGradient stop g mask use rect circle polygon 等)・button(default action は popup 側で抑制)
  • 許可属性: class role aria-labelsrc alt width height・SVG 描画属性(viewBox fill d transform 等)・SVG id(rename 対象)
  • 削除する属性: href[hidden]styleon*data-v-*

3.アルゴリズム詳細

3-1. SVG namespace の維持

素朴に document.createElement('path') すると HTML namespace の Unknown element になり、SVG として描画されない。document.createElementNS('http://www.w3.org/2000/svg', 'path') で SVG namespace を明示する必要がある。本 sanitizer は再帰的に DOM を walk しながら、SVG 系タグだけ createElementNS で再生成する。

3-2. defs id の nonce 付き rename

配信ページの SVG はしばしば <linearGradient id="grad-rank-1"> のように静的な idで gradient / mask を定義し、fill="url(#grad-rank-1)"<use xlink:href="#icon-cup"/> で参照している。

popup に複数の mirror(例: イベント累計スコアと現在順位を同時表示)を並べると、同じ id を持つ defs が複数登場し、ブラウザはどれを参照するか不定になる(多くの場合最初の defs を全 use が参照する事故が起きる)。

本 sanitizer は sanitize 開始時に nonce = Math.random().toString(36).slice(2, 10) を生成し:

  1. defs 配下の id をすべて {元 id}__{nonce} に rename し、map に登録
  2. 同じ DOM 木の url(#xxx) / xlink:href="#xxx" を map で書き換え

これにより複数 mirror が同居しても id 衝突しない。

3-3. [hidden] 要素の削除

配信ページの実 DOM には hidden 属性付きの「描画されていない要素」が混じることがある(lazy mount 前の placeholder、A/B test の隠し UI など)。これらは元ページでは CSS で display:none 相当だが、popup 側の CSS が違うとうっかり表示されてしまう。sanitize 時にツリーから完全に削除する。

3-4. href の全削除

配信ページの広告ランキング DOM には「ニコニ広告のページへ」「ユーザーページへ」のような外部リンクが埋まっていることがある。popup 内でクリックされると、popup が自動 close してリンクが新規タブ or 同タブで開く挙動になり、配信者の意図しない画面遷移を起こす。href 属性を全削除することで「視覚情報だけを映す」状態にする。

4.参考実装(JavaScript / 純粋関数の抜粋)

// ALLOWED_TAGS / ALLOWED_ATTRS は事前定義のホワイトリスト Set

function sanitizeMirrorHtml(htmlString) {
  if (typeof htmlString !== 'string' || !htmlString.trim()) return '';
  if (typeof DOMParser === 'undefined') return '';

  const doc = new DOMParser().parseFromString(
    `<div>${htmlString}</div>`, 'text/html'
  );
  const root = doc.body.firstElementChild;
  if (!root) return '';

  const nonce = Math.random().toString(36).slice(2, 10);
  const idMap = new Map();

  // pass 1: id を nonce 付きに rename + map に登録
  root.querySelectorAll('[id]').forEach(el => {
    const oldId = el.getAttribute('id');
    const newId = `${oldId}__${nonce}`;
    idMap.set(oldId, newId);
    el.setAttribute('id', newId);
  });

  // pass 2: url(#xxx) / xlink:href="#xxx" を rewrite
  rewriteIdReferences(root, idMap);

  // pass 3: 許可タグ・許可属性のみ通すホワイトリスト walk
  return walkSanitize(root, nonce).innerHTML;
}

function walkSanitize(node, nonce) {
  const ns = 'http://www.w3.org/2000/svg';
  const tag = node.tagName.toLowerCase();
  if (!ALLOWED_TAGS.has(tag) || node.hasAttribute('hidden')) {
    return null;
  }
  const isSvg = SVG_TAGS.has(tag);
  const clone = isSvg
    ? document.createElementNS(ns, tag)
    : document.createElement(tag);
  for (const attr of node.attributes) {
    const name = attr.name.toLowerCase();
    if (!ALLOWED_ATTRS.has(name)) continue;
    if (name === 'href') continue; // 明示削除
    clone.setAttribute(name, attr.value);
  }
  for (const child of node.childNodes) {
    if (child.nodeType === 3) clone.appendChild(child.cloneNode());
    else if (child.nodeType === 1) {
      const sub = walkSanitize(child, nonce);
      if (sub) clone.appendChild(sub);
    }
  }
  return clone;
}

完全実装: src/lib/mirrorSanitize.js(223 行、vitest 14 件で SVG namespace / id rewrite / 削除属性を網羅検証)

5.なぜこの設計なのか

5-1. なぜ DOMPurify を採用しなかったのか

DOMPurify は熟成された定番ライブラリで、本手法と同じ目的を達成できる。本プロジェクトでは ランタイム依存ゼロ方針(拡張本体に外部 npm package を bundle しない)を採っており、必要最小限のホワイトリストを自前で持つ方を選んだ。本手法では SVG defs の id 衝突対策・[hidden] 完全削除のような本プロジェクト固有の要件を直接コードに書き下せる利点もある。DOMPurify を採用する場合は SAFE_FOR_TEMPLATES / FORBID_TAGS / FORBID_ATTR 設定でほぼ同等の挙動を作れる。

5-2. なぜ document.createElement ではなく createElementNS なのか

SVG タグは HTML namespace では Unknown element として parse され、レイアウトされない。createElementNS('http://www.w3.org/2000/svg', tag) で SVG namespace を付与しないと描画されない。これは MDN Document.createElementNS() にも明記されている挙動。

5-3. なぜ id rename の nonce はランダム文字列か

連番(id__1, id__2, ...)にすると、popup を開き直すたびに同じ番号が振られ、別 mirror 間で衝突する。Math.random() ベースで生成すれば衝突確率が実質ゼロになる。secret 用途ではないので CSPRNG(crypto.getRandomValues)は不要。

6.既知の限界と拡張可能性

  • CSS が popup 側に追従しない: 配信ページの class 名(例: .score)を popup 側 CSS で再現する必要がある。プラットフォーム側の CSS 変更で見た目が崩れる場合がある(mitigation: 主要 class を popup 側に複製、変更検知用の単体テストを書く)。
  • JavaScript で描画される要素は捕捉不能: 例えば canvas / Vue mount 後に動く要素は outerHTML 時点のスナップショットしか取れない。動的更新が必要な場合は別経路(postMessage で差分送信)を併用する。
  • cross-origin iframe 内の DOM は取れない: 配信ページが iframe で別 origin を埋めている場合、親 frame の content script は子 iframe にアクセスできない。拡張の all_frames=true + matches で子 iframe にも content script を inject する必要がある。
  • 拡張: diff merge。再描画時に DOM 全体を入れ替えず、差分のみ patch することで scrollY / focus を保持できる。
  • 拡張: shadow DOM 隔離。popup 内に shadow root を作って sanitize 済 DOM を入れれば、popup 本体の CSS と干渉しない。

7.関連する既知技術(先行技術)

7-1. DOMPurify (Cure53)

定番の HTML/XML/SVG sanitizer。本手法と同じく許可リスト方式で動作し、SVG namespace も正しく扱う。本手法の自前 sanitizer は DOMPurify を機能サブセットとして再実装したものに相当する。

7-2. Sanitizer API (W3C draft)

ブラウザネイティブの Element.setHTML(htmlString, { sanitizer }) を提供する W3C ドラフト仕様。Chrome では origin trial を経て段階的に implemented。本手法は仕様確定前の Chrome 拡張で広く動かす必要があるため独自実装を選んだが、将来的にネイティブ API への移行は可能。

7-3. SingleFile(Chrome 拡張による Web ページ保存)

Web ページの DOM 全体を 1 つの HTML に保存する拡張機能。outerHTML の sanitize と inline 化を独自実装しており、本手法と設計思想が近い(依存最小・ホワイトリスト方式)。SVG defs の id 衝突対策は本手法の方が踏み込んでいる(SingleFile は単一ページ前提なので id 衝突を扱う必要がない)。

7-4. Mozilla Readability

ニュース記事を本文だけに抜き出す sanitizer。許可タグセットが本手法より広く、SVG は基本除外する設計。本手法はランキング表示のため SVG(icon / gradient)を残す必要があり、設計判断が逆。

8.本記事の位置づけ・ライセンス

本記事は、Chrome 拡張機能『君斗りんくの追憶のきらめき』内で 2026 年 5 月 9 日(バージョン 0.1.237)に投入された mirrorSanitize.js モジュールおよび、v0.1.240 / v0.1.242 で公式値レーンの 4 レーン(広告ランキング・イベント累計スコア・現在順位・番組累計ポイント)に展開した設計思想を、2026 年 5 月 11 日付で公開するものである。

参考実装は MIT、本記事文章は CC BY 4.0 のもとで自由に複製・引用可。

引用例: 君斗りんく「Chrome 拡張で配信ページの DOM を popup に『鏡のように貼り付ける』ホワイトリスト方式 sanitizer」君斗りんくの追憶のきらめき、2026 年 5 月 11 日公開、https://tsuioku-no-kirameki.com/articles/north-star-mirror-lanes.html