Trusted Types を使ったクライアントサイドのクロスサイトスクリプティング脆弱性を防ぐ方法
この記事は以下の記事の日本語訳です。
はじめに
この投稿では、Content-Security-Policy (CSP) 機能の一部として「Trusted Types」ディレクティブを使用し、クロスサイトスクリプティング (XSS) 攻撃から保護する方法を説明します。まず、DOM ベースでクライアントサイドの危険な API を使った場合の XSS 攻撃がどのように動作するかを解説します。
その後、どうやって Trusted Types を利用してこうした API の脆弱性を封じ込めるのか、そして Trusted Types がどう動作するのかを詳しく紹介します。さらに、Trusted Types に対応するための具体的な方法(安全な API の使用、サニタイズライブラリの利用、ポリシーを自作して Trusted Types を生成する方法)をいくつか取り上げます。
注: Trusted Types API は現在 Chromium ベースのブラウザでのみ使用できます。Chromium 83(2020年リリース)以降では標準で利用可能です。
DOM ベースのクライアントサイド XSS
XSS は、ユーザーからの入力を無加工で(サニタイズせずに)HTML ページに挿入してしまうことで生じます。サーバーサイドでも Razor で @Html.Raw() のように危険な取り込み方をすると XSS を招くことはよく知られていますが、クライアントサイドでも同様の問題があります。
たとえば URL のクエリパラメータや postMessage チャネル経由のユーザー入力を、innerHTML や eval、setTimeout などの危険な API に直接渡すと、DOM ベースの XSS 攻撃が可能になってしまいます。
サンプルコード
以下は、ASP.NET Core アプリから返される非常にシンプルな HTML ページの例です。target という div 要素があり、初期テキストを「To be replaced」としています。その下にあるスクリプトでは、URL のクエリ文字列から username パラメータを取得し、innerHTML を使って <h1> として表示します。
<div id="target">To be replaced</div>
<script type="text/javascript">
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
// ユーザー名を挿入
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
target.innerHTML = inner;
</script>
単にユーザーが普通の名前を入力して閲覧する場合には問題があるようには見えません。しかし、このコードには深刻な XSS 脆弱性があります。
XSS 攻撃の例
攻撃者は、以下のような URL をユーザーに踏ませることで攻撃を仕掛けられます。
http://localhost:5000/?username=<img src=x onerror="alert('XSS Attack')">
このクエリ文字列によって、
- username に <img src=x onerror="alert('XSS Attack')"> が入り、
- <h1> タグと結合すると <h1><img src=x onerror="alert('XSS Attack')"></h1> となり、
- innerHTML によって target 要素に直接挿入されます。
<img> タグは src="x" を読み込もうとして失敗し、onerror イベントが発生して alert が実行されてしまうのです。結果として、以下のように XSS 攻撃が成立します。
補足: 単純な<script>タグをinnerHTMLで追加しても実行されない仕様がありますが、onerrorのような別の経路で実行される場合があるため、安全とは言えません。
Trusted Types と CSP で XSS をブロック
Content-Security-Policy (CSP) とは
モダンアプリケーションの主要なセキュリティ機能として、Content-Security-Policy (CSP) ヘッダが知られています。CSP には、HTTPS の強制、特定ホストやリソースのみ許可、特定ハッシュを持つスクリプトのみ許可、など多くの機能があります。
本記事で扱うのは、その中でも Trusted Types(W3Cのドラフト仕様)を利用する部分です。具体的には、require-trusted-types-for ディレクティブを使います。
require-trusted-types-for 'script' を追加する
NetEscapades.AspNetCore.SecurityHeaders ライブラリを使えば、ASP.NET Core でレスポンスヘッダを簡単に構築できます。以下の例では、シンプルな Trusted Types ディレクティブを CSP に追加しています。
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
// ポリシーを設定
app.UseSecurityHeaders(new HeaderPolicyCollection()
.AddContentSecurityPolicy(config =>
{
// require-trusted-types-for 'script' を追加
config.AddRequireTrustedTypesFor().Script();
})
);
app.Run();
これによって、レスポンスヘッダに以下のような行が加わります。
Content-Security-Policy: require-trusted-types-for 'script'
そしてこの状態で先ほどの innerHTML を用いた攻撃を試すと、ブラウザ側でエラーが発生し、実行がブロックされます。
ブラウザは require-trusted-types-for 'script' を受け取り、innerHTML や eval といった「スクリプトの実行につながりうる API」には、単なる文字列ではなく TrustedHTML や TrustedScript といった特別なオブジェクトしか渡せないモードになるのです。
Trusted Types 違反の修正方法
require-trusted-types-for 'script' を有効にすると、上記のように攻撃は防げますが、同時に本来の機能を実装するために innerHTML などを使いたいケースも出てきます。この章では、そうしたケースをどのように対処すればいいのか説明します。
危険な API を避ける
最も安全なのは、そもそも innerHTML のような危険な API を使わないよう書き換えることです。たとえば <h1> を表示するだけなら、以下のように createElement や textContent、appendChild を使えます。
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
// createElement で要素を生成し、textContent でテキストを設定
const h1 = document.createElement('h1');
h1.textContent = username;
target.textContent = '';
target.appendChild(h1);
こうすると innerHTML を使う必要がなくなり、XSS 攻撃も基本的に防げます。しかし、既存コードが大量にある場合や、ユーザーに部分的な HTML 入力を許可したい場合などは、すべてをこの方法に書き換えるのは難しいかもしれません。
サニタイズライブラリを使う
「HTML タグの一部は許可したいが、悪意あるスクリプト実行は防ぎたい」という場合には、サニタイズライブラリが現実的な選択肢になります。
ただし、ライブラリ自体の安全性には注意が必要です。もしライブラリに脆弱性があれば、Trusted Types を使っていても攻撃を防げない可能性があります。
ここでは DOMPurify を例に説明します。まずスクリプトを読み込み、以下のように DOMPurify.sanitize() を呼び出します(RETURN_TRUSTED_TYPE を有効にすると、TrustedHTML が返ってきます)。
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
const cleaned = DOMPurify.sanitize(inner, { RETURN_TRUSTED_TYPE: true });
target.innerHTML = cleaned;
危険なコードはライブラリ側で除去・無害化されるため、結果として XSS 攻撃が起きなくなります。
TrustedHTML 生成を制御する
サニタイズライブラリや API 書き換えで対処する方法を見ましたが、「任意の JavaScript が勝手に TrustedHTML を作れてしまったらどうなるのか?」と疑問に思うかもしれません。
ここでもう一つの CSP ディレクティブである trusted-types が登場します。たとえば以下のように設定することで、許可されたポリシー以外の TrustedTypePolicy は作れなくなります。
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
builder.AddTrustedTypes().AllowPolicy("my-policy");
});
これによりレスポンスヘッダに
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-policy
が追加されます。すると、もし DOMPurify が内部で dompurify という名前のポリシーを作成しようとしても、CSP で許可されていないためエラーになります。
DOMPurify を通して使いたい場合は、たとえば次のように "dompurify" ポリシーも明示的に許可するといった対応が必要です。
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
builder.AddTrustedTypes()
.AllowPolicy("my-policy")
.AllowPolicy("dompurify"); // DOMPurifyで使われるポリシー名
});
独自の Trusted Type ポリシーを作成する
一般には、自前でポリシーを作成するのは最後の手段です。まずは危険な API を使用しない、どうしても必要ならサニタイズライブラリを活用する、というのがセキュアな設計です。それでも回避できない場合などに限り、独自ポリシーで対応することになります。
移行期には、一時的に自作ポリシーが必要なこともありますが、最終的には減らしていくのが望ましいです。
Trusted Type ポリシーを作るには、文字列を入力として受け取り、サニタイズして安全な文字列を返す関数を window.trustedTypes.createPolicy() に渡します。たとえば以下のように(あまり安全とは言えない例ですが)シンプルな実装を行えます。
// trusted-types 非対応ブラウザ向けのフォールバック
let sanitizeHtmlPolicy = {
createHTML: x => x
};
// 機能検出
if (window.trustedTypes && trustedTypes.createPolicy) {
// "my-policy" という名前でポリシーを作る(CSP 側で許可されていることが前提)
sanitizeHtmlPolicy = trustedTypes.createPolicy('my-policy', {
createHTML: (toEscape) =>
toEscape
.replace(/</g, '<')
.replace(/>/g, '>')
});
}
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
// ポリシーを使って TrustedHTML を生成
const cleaned = sanitizeHtmlPolicy.createHTML(inner);
target.innerHTML = cleaned;
ここでは単純に < と > を < と > に置き換えているだけですが、本来ならもっと高度なサニタイズを行う必要があります。
デフォルトポリシーを作成する
Trusted Types に移行しようとすると、アプリ全体のコードで使われている危険な API を一挙に書き換えないと動かない、という問題が出ます。これを段階的に進めるために、「default」ポリシーを作ることができます。
default ポリシーは、何らかの理由でポリシー指定なしに文字列を代入しようとしたときに使われます。
以下は警告を出すだけのパススルー例です(セキュアではありませんが、移行期の暫定対応例として)。
let sanitizeHtmlPolicy = {
createHTML: x => x,
createScript: x => x,
createScriptURL: x => x
};
if (window.trustedTypes && trustedTypes.createPolicy) {
sanitizeHtmlPolicy = trustedTypes.createPolicy('default', {
createHTML: toEscape => {
console.log('Warning: use of default createHTML policy.');
return toEscape
.replace(/</g, '<')
.replace(/>/g, '>');
},
createScript: toEscape => {
console.log('Warning: use of default createScript policy.');
return toEscape;
},
createScriptURL: toEscape => {
console.log('Warning: use of default createScriptURL policy.');
return toEscape;
},
});
}
CSP 側では以下のように default ポリシーを許可します。
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
builder.AddTrustedTypes().Default();
});
こうすると、本来ならエラーになる代入が起きたときにも「default ポリシー」が呼ばれて動作しつつ、コンソールに警告を出してくれます。どの箇所が古い実装のままなのかを把握できるので、移行を段階的に進める際に役立ちます。
まとめ
本記事では、ブラウザの特定 API(innerHTML など)がクロスサイトスクリプティングに対して脆弱な理由と、それを require-trusted-types-for 'script' と trusted-types の 2 つの CSP ディレクティブを利用して防ぐ方法を紹介しました。
危険な API の削除、サニタイズライブラリ(DOMPurify など)の活用、あるいは自作ポリシーでの対策、そして大規模なコードベースに徐々に導入するための default ポリシー活用など、状況に応じていろいろな手段を組み合わせることができます。