1.解決したい課題
Chrome 拡張機能のポップアップに、視聴中の配信ページ(タブ)が表示している公式値レーン(広告ランキング・イベント累計スコア・現在順位・番組累計ポイント等)を、配信者が popup を開いた瞬間にそのままの見た目で並べたい。
選択肢は通常 3 つある:
- (A) JSON で値だけ取得して拡張側で再描画: プラットフォーム本体の見た目とずれる。CSS 追従が継続的にコストになる。
- (B) iframe で配信ページの該当区画を埋め込み: cross-origin で操作不可、scroll や Vue mount の問題で表示が壊れやすい。
- (C) DOM の
outerHTMLをそのまま popup に流す: 見た目は完璧に揃うが、XSS・画面遷移事故・id 衝突のリスクが新規発生する。
本記事は (C) を選択した場合に必要な、自前ホワイトリスト sanitizerの設計を扱う。
2.手法の概要
「鏡のように貼り付ける」フロー:
- 配信ページ側の content script で対象 DOM(例:
<span class="score">)のouterHTMLを取得 - chrome.runtime.sendMessage で popup 側へ HTML 文字列を渡す
- popup 側で
DOMParserによりサンドボックスで parse - ホワイトリスト方式 sanitizerで許可タグ・許可属性のみ通し、SVG defs の
idを nonce 付きに rename - 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-label・src alt width height・SVG 描画属性(viewBox fill d transform等)・SVGid(rename 対象) - 削除する属性:
href・[hidden]・style・on*・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) を生成し:
- defs 配下の
idをすべて{元 id}__{nonce}に rename し、map に登録 - 同じ 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 のもとで自由に複製・引用可。
https://tsuioku-no-kirameki.com/articles/north-star-mirror-lanes.html