HTMLのサニタイズとCSP
HTMLの許可範囲とスクリプト実行の制限を、別の防御として組み合わせます。
説明
投稿に装飾を許す場合、すべてをtextContentにすると装飾も文字として表示されます。必要な要素だけを許す処理と、スクリプト実行を制限するCSPは役割が違います。
固定の装飾投稿を使い、要素と属性の許可範囲を絞ります。安全なDOMへイベントを登録する方法を確認し、最後に独立したフレームでnonceあり・なしのCSPの実行結果を比較します。
HTMLの許可範囲とスクリプト実行の制限を、別の防御として組み合わせます。
1装飾とイベント属性を含む投稿
試料の許可する装飾はemとstrongだけです。onclick属性とbutton要素は許可しない方針にします。
window.host = document.getElementById("scratch");
window.rich =固定の装飾投稿を用意する `<em onclick="document.body.dataset.demo='ran'">note</em><button>action</button><strong>important</strong>`;
console.log(rich);装飾として必要な要素と、不要なイベント属性・ボタンが混ざっています。文字の置換だけでは構造を把握できません。
2許可する要素だけでDOMを作り直す
formatOnlyは試料専用の小さな変換です。HTMLのemとstrong以外は文字だけを残し、属性はすべて落とします。
host.replaceChildren(formatOnly(rich)許可した装飾だけで新しいDOMを作る);
console.log(host.innerHTML);
console.log(host.querySelectorAll不要な属性と要素の残存を調べる("[onclick],button").length);emとstrongの装飾だけが残り、onclickとbuttonは0件です。汎用のHTML受入れには、保守されているsanitizerと用途に合う設定を使います。
3残した装飾をクリックする
変換後のemをクリックし、入力中のイベントが残っていないか確かめます。
document.body.dataset.demo = "idle";
host.querySelector("em").click()変換後の要素をクリックする;
console.log(document.body.dataset.demo);idleのままです。許可した装飾の要素であっても、イベント属性まで許す必要はありません。
4アプリ側の処理を別に登録する
アプリが作るボタンと、利用者が入力する表示文字を分けます。
window.button = document.createElement("button");
button.textContent利用者の表示文字を文字として扱う = "<strong>開く</strong>";
button.addEventListenerアプリ側の処理を明示して登録する("click",()=>{document.body.dataset.demo="app-handler";});
host.replaceChildren(button);
button.click();
console.log(button.children.length,document.body.dataset.demo);表示文字は要素にならず、アプリが登録したイベントだけでapp-handlerへ変わります。入力文字をコードへ組み立てていません。
5インライン実行を広く許すCSPと比較する
cspTrialは独立したフレームで二つの短いスクリプトを実行し、結果を返します。外部資源はdefault-src noneで禁止しています。
console.log(await cspTrial("inline")インライン実行を許す方針を検証する);inlineとnonce付きの両方が実行されます。unsafe-inlineでは、本文へ混ざったスクリプトも実行対象になり得ます。
6nonceが一致するスクリプトだけを許す
試料フレームはscript-srcに固定の演習用nonceを設定します。実運用では応答ごとに予測できないnonceを生成します。
console.log(await cspTrial("nonce")nonceに一致するスクリプトだけを許可する);nonceなしのinlineはFalse、nonce付きはTrueです。CSPが実行を止めても、HTMLの構造変更や誤ったリンクなどの問題まで直したことにはなりません。
7CSPとDOMの安全性を別々に保つ
装飾の変換をもう一度行い、属性と要素の結果を確認します。
host.replaceChildren(formatOnly(rich)装飾の許可方針を再適用する);
console.log(host.innerHTML,host.querySelectorAll("[onclick]").length);CSPの有無に依存せず、入力のイベント属性を取り除く処理を保ちます。出力先の修正と追加の実行制限を組み合わせる設計です。
まとめ
装飾の許可範囲では、要素と属性を別々に制限します。
表示文字とアプリのイベント処理を分けます。
CSPは追加の実行制限として使い、危険な出力処理の修正も行います。
操作履歴と解説を振り返れます。
JavaScript / ブラウザのDOM・CSP / 固定のHTML試料
window.host = document.getElementById("scratch");
window.rich = `<em onclick="document.body.dataset.demo='ran'">note</em><button>action</button><strong>important</strong>`;
console.log(rich);<em onclick="document.body.dataset.demo='ran'">note</em><button>action</button><strong>important</strong>
host.replaceChildren(formatOnly(rich));
console.log(host.innerHTML);
console.log(host.querySelectorAll("[onclick],button").length);<em>note</em>action<strong>important</strong> 0
document.body.dataset.demo = "idle";
host.querySelector("em").click();
console.log(document.body.dataset.demo);idle
window.button = document.createElement("button");
button.textContent = "<strong>開く</strong>";
button.addEventListener("click",()=>{document.body.dataset.demo="app-handler";});
host.replaceChildren(button);
button.click();
console.log(button.children.length,document.body.dataset.demo);0 app-handler
console.log(await cspTrial("inline"));{"inline":true,"nonce":true}console.log(await cspTrial("nonce"));{"inline":false,"nonce":true}host.replaceChildren(formatOnly(rich));
console.log(host.innerHTML,host.querySelectorAll("[onclick]").length);<em>note</em>action<strong>important</strong> 0
演習完了