Sanitizer APIの使い方
UI開発者 加藤本記事の内容は古い仕様をもとに書かれています。「Sanitizer APIのその後」でその後の内容を書いていますので、そちらもぜひご覧ください。
JavaScriptでは、ユーザーが入力した情報、URLに付与されているクエリ、Cookieの情報など外部からの入力をそのままDOMに反映すると、DOMベースのXSS脆弱性を生み出してしまうという問題があります。
これを避けるために、当社内で定義しているフロントエンド・スタイルガイドでは「HTMLをDOMに直接追加しない」というルールを必須項目として立てており、以下の様に定めています。
- HTMLではなくテキストとして追加する
- DOMオブジェクトとして組み立てて追加する
- DOMPurifyなどのライブラリでエスケープしてから追加する
スタイルガイド内に特定ライブラリの使用を推奨する記載をしているのはこの1カ所だけです。
理由としては、いち開発者がスクラッチで実装するよりも歴史のあるライブラリに頼り、より多くの脆弱性をつぶす必要があることを優先したほうがよいという判断です。もちろん必要に応じて、という前提付きではあります。
しかし、Sanitizer APIの登場により状況は変わりつつあります。
Sanitizer APIとは
Sanitizer APIとはその名の通り、特定の文字列をサニタイジングをするためのAPIです。2022年2月現在ではまだWICGによって管理されている仕様であり、どのブラウザにも正式には実装されていません。
英単語としての「Sanitize」には、消毒する、きれいにするといった意味が含まれており、JavaScriptにおけるSanitizer APIでは以下のような使い方ができます。
- HTML文字列から特定の要素のみ、もしくは特定の要素とその子要素も丸ごと取り除いたHTMLを取得する
- HTML文字列から特定の属性を除いたHTMLを取得する
- HTML文字列からコメント(
<--
と-->
で囲われている部分)を除いたHTMLを取得する
XSSを防ぐのがSanitizer APIの目的ではありますが、プロジェクト内で使用を禁止したい要素や属性がある場合にも利用できそうですね。
Sanitizer APIの使い方
多くのライブラリは文字列ベースのサニタイジングを行いますが、Sanitizer APIは処理パフォーマンスの観点や、後述するコンテキストに関する観点から文字列ベースではなく、Nodeベースのサニタイジングを行います。
利用方法は大きく分けてsetHTML
メソッドの引数に指定する方法と、Sanitizer APIを直接使う方法の2通りあります。
setHTML
メソッドの引数に指定する
setHTML
メソッドはElementが持つメソッドです。
このメソッドはSanitizer APIと組み合わせて利用することを想定されており、まだどのブラウザにも実装されていません。
使い方は以下の通りです。
const target = document.getElementById('insertTarget');
const userInputStrings = '<p>ユーザーが入力したテキスト <img src="deadlink" onerror="alert(1)"></p>';
const sanitizer = new Sanitizer(); // Sanitizerのインスタンスを作成
target.setHTML(userInputStrings, sanitizer); // インスタンスを第2引数に指定する
setHTML
メソッドの第2引数にSanitizerインスタンスを指定することで、サニタイジングされたHTMLElementをtarget
のinnerHTML
としてセットできます。
Sanitizer APIでは、スクリプトが実行される可能性のある要素や属性はデフォルトで削除されるため、サニタイジング後のtarget
のinnerHTML
は以下のようになります。
<p>ユーザーが入力したテキスト <img src="deadlink"></p>
onerror
の部分が削除されているのが分かります。
Sanitizer APIを直接使う
Sanitizer APIにはsanitize
とsanitizeFor
というメソッドが定義されています。
sanitize
は引数にDocumentFragmentをとりDocumentFragmentを返却するメソッドで、sanitizeFor
は引数に文字列をとりHTMLElementを返却するメソッドです。以下はsanitizeFor
の使用例です。
const userInputStrings = '<p>ユーザーが入力したテキスト <img src="deadlink" onerror="alert(1)"></p>';
const sanitizer = new Sanitizer();
const sanitized = sanitizer.sanitizeFor('div', userInputStrings);
console.log(sanitized instanceof HTMLDivElement); // true
sanitizeFor
の第1引数には、サニタイジング対象の文字列がどの要素に含まれる想定なのかを示す「コンテキスト」を指定する必要があります。
HTMLのパースとコンテキスト
HTML文字列のパースにはコンテキスト情報が必要です。例えばGoogle Chromeの場合<td>
を<table>
のinnerHTML
にセットした結果と、<div>
のinnerHTML
にセットした結果では、次のような差異が発生します。
div.innerHTML = '<td>divに追加したtd</td>'
の結果
<div>divに追加したtd</div>
table.innerHTML = '<td>tableに追加したtd</td>'
の結果
<table>
<tbody>
<tr>
<td>tableのinnerHTMLにtd要素をセット</td>
</tr>
</tbody>
</table>
挙動の差異をつくmXSS
<div>
にセットした場合は<td>
が削除され、テキストノードだけが<div>
に追加されます。対して<table>
にセットした場合は<tbody>
や<tr>
も<td>
と同時に追加されます。
これはinnerHTML
にセットした文字列と、セットしたのちにinnerHTML
から取得できるHTMLに差異があることを示しており、このような差異を利用して任意のコードを実行する攻撃をmutation XSS(mXSS)と呼びます。
mXSSについてはSanitizer APIのGitHubに簡単な説明があるため、気になる方は参照ください。
mXSS攻撃を防ぐためには、文字列を正しいHTMLにパースする必要があり、そのためにはコンテキストを指定する必要があります。
同様に文字列のパースを要するサニタイジング、つまりsanitizeFor
メソッドにも同じくコンテキストを指定する必要があります。
const sanitized = sanitizer.sanitizeFor('div', userInputStrings); // 'div'の部分がコンテキスト
Sanitizer API利用上の注意
Sanitizer APIは特にオプションを指定せずに使ってもデフォルトで安全なHTMLを作成するよう実装されています。
しかし、プロジェクトの要件は複雑化してきており、ものによってはオプションを設定して固有なサニタイジングの実施が必要なケースもあります。どのようなサニタイジングをするべきかは、時々によって変わるため開発者の責務としてしっかり検討していきましょう。
前述したコンテキストについては開発者が書くコードに依存する部分となるため特に注意が必要であるほか、仕様にはSanitizer APIでは防げないシナリオも記載がありますので、見ておくとよいかもしれません。
Sanitizer APIはまだどのブラウザにも実装されていませんが、現在Google ChromeではSanitizer APIはオリジントライアルを行っています。多様なテストが行われれば、API自体の精度をあげることにつながり、多くの開発者がその恩恵を受けることができます。積極的に試してみましょう。
おわりに
ミツエーリンクスではUI開発者(フロントエンドエンジニア)を募集しています。
Web標準技術に興味のある方、表示パフォーマンスの改善に取り組みたい方、大規模なサイトの設計・実装にチャレンジしたい方などなどいらっしゃいましたら、ぜひ採用サイトからご応募ください!