Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

Strict CSP を Content Security Policy Level 3 に対応したブラウザに絞って適用する

こんにちは、プロダクト本部エンジニアの中村 (kozy4324) です。

現在 Classi が提供している Web サービスでは Content Security Policy を導入しています。その導入時の話は以下の記事で紹介させてもらいました。

今回の記事では、運用を続けていく中でわかったことや出てきた課題、またそれらを踏まえて現在どういった CSP のポリシーで運用を行っているのか紹介します。

オリジンの許可リストをベースにしたポリシー

導入時の記事でも紹介している通り、運用開始時のポリシーは以下のようなものでした。

Content-Security-Policy:
  default-src 'self';
  script-src  example.com 'sha256-xxx' 'nonce-hogehoge111';
  style-src   example2.com 'sha256-yyy';

ベースは ’self’ (文書が提供されたオリジンと同一オリジンのリソースを許可する)とホスト名による許可リスト方式で、特定のインラインコードやインラインスタイルを追加で許可したい場合にハッシュ値や nonce を追加しています。

最初はできる限り厳格な規格に沿ってポリシーを設定、違反レポートを確認しながら必要に応じてポリシーを追加、もしくは無害と判断できるものは無視するという運用ルールとしていました。

出てきた課題1: オリジンの許可リストベースでは攻撃とは見なせない違反レポートが雑多に出てくる

上記ポリシーで運用を始めたところ、すぐに多くの違反レポートが日々あがってくる状況になりました。ところがそのほとんどがブラウザ拡張機能や 3rd party 製の何かによる img や style 、script の挿入に見受けられ、総合的にみて「攻撃とは見なせない」という判断になるものばかりでした。

Sentryに収集された実際の違反レポートの一部

違反レポート自体にはそれほど多くの情報は含まれておらず、頻度やブラウザの分布なども確認しながら一つ一つ丁寧に調査していく必要があります。新しいレポートが発生するたびに調査対応していくと運用負荷は大きなものになっていきました。

Strict CSP をベースにしたポリシーへの変更

オリジンの許可リスト方式で厳密に制限して運用していくのは運用負荷が大きいということが分かりました。また許可リスト方式では CSP バイパスの問題も指摘されています。なので方針を変更し、nonce + strict-dynamic による Strict CSP をベースにしたポリシーとすることにしました。Strict CSP では XSS に対する防御に重点を置き、基本的にはスクリプトに対してのみ制御を行います。XSS に対して効果の薄いスクリプト以外のリソースには制御を行わないため、スクリプト以外で発生する雑多な違反レポートが抑止されることを期待しました。

Strict CSP をベースにしたポリシーは以下のようになります。

Content-Security-Policy:
  object-src 'none';
  script-src 'nonce-{random}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;
  base-uri   'none';
  report-uri https://your-report-collector.example.com/

この設定内容は、例えば strict-dynamic をサポートしていないブラウザでのフォールバックが含まれています。 Strict CSP のページでも説明がなされていますが、より詳しく整理したものが以下です。

  • CSP Level 3 まで対応しているブラウザの場合
    • 'nonce-{random}' が採用されるので 'unsafe-inline' は無視される
    • 'strict-dynamic' が採用されるので https: http: は無視される
    • よって nonce + strict-dynamic で許可されたスクリプトだけが実行される
  • CSP Level 2 まで対応しているブラウザの場合
    • 'nonce-{random}' が採用されるので 'unsafe-inline' は無視される
    • 'strict-dynamic' が解釈できないので https: http: が採用される
    • よって nonce で許可されたインラインスクリプトと外部スクリプトだけが実行される
    • nonce が付与されないインラインスクリプトは実行が制限される
  • CSP Level 1 まで対応しているブラウザの場合
    • 'nonce-{random}' が解釈できないので 'unsafe-inline' が採用される
    • 'strict-dynamic' が解釈できないので https: http: が採用される
    • CSPによる実行制限はない(保護されない)
  • CSPに対応していないブラウザの場合
    • CSPヘッダは何も作用しない
    • CSPによる実行制限はない(保護されない)

出てきた課題2: Strict CSP ベースでは一部ブラウザで Google Tag Manager による違反レポートが発生する

Classi サービスでは Google Tag Manager を利用している箇所がいくつかあり、Google Tag Manager が動的に追加するスクリプトで違反レポートが発生しました。これは一部のブラウザのみで発生し、確認したところ CSP Level 2 まで対応しているブラウザ、2024年5月時点では iOS 14.x〜15.3 の Mobile Safari が該当しました。Classi サービスにおいてまだ一定数のアクセスがある状態です。

CSP Level 2 まで対応しているブラウザでは strict-dynamic は解釈されないため、動的に追加するスクリプトの実行を許可するには nonce を付与する必要があります。 Google Tag Manager のガイドに nonce 対応バージョンの言及があるので、この nonce 対応バージョンとすることで問題なく CSP の実行許可がなされると思われましたが、一つ落とし穴がありました。Google Tag Manager の「カスタム HTML 」で追加されるスクリプトタグには nonce を付与することができませんでした。補足として Google Tag Manager 上で「 document.write をサポートする」というオプションを有効にすることで nonce を付与することは可能になりますが、 Classi での利用方法的にこのオプションを有効にすることもできませんでした。

CSPで制御されるスクリプトの種類とディレクティブの整理

ここまでに遭遇した課題をうまく回避できる CSP 設定はないものかと思い、スクリプトの種類とディレクティブについて整理をしてみました。

種類 具体例 制御ディレクティブ ‘self’ と host-source による許可 nonce による許可 strict-dynamicによる許可
外部スクリプト <script src="outer.js"></script> script-src-elem できる できる できる
インラインスクリプト <script>alert(1)</script> script-src-elem できない できる できる
インラインイベントハンドラー <a onclick="alert('clicked')">link</a> script-src-attr できない できない できない
JavaScript URL <a href="javascript:void(0);">link</a> script-src-elem できない できない できない

a タグの href 属性に設定する javascript: で始まるスクリプト記述は script-src-attr ではなく script-src-elem で制御されるというのは押さえておきたいポイントです。

またこの整理の中で strict-dynamic 以外に script-src-elem と script-src-attr の両ディレクティブについても CSP Level 3 からの機能であることに気づきました。 CSP Level 3 に対応していないブラウザではこれらのディレクティブは単純に無視されます。

Strict CSP を CSP Level 3 に対応したブラウザに絞って適用する

上記の整理を行うことで、以下の CSP 設定を導き出すことができました。

Content-Security-Policy:
  object-src      'none';
  script-src      'report-sample' 'unsafe-inline' 'unsafe-eval' https: http:;
  script-src-elem 'report-sample' 'nonce-xxxxx' 'strict-dynamic';
  script-src-attr 'report-sample' 'none';
  base-uri        'none';
  report-uri      https://your-report-collector.example.com/;

この設定における整理は以下の通りです。

  • CSP Level 3 まで対応しているブラウザの場合
    • script-src-elem, script-src-attr によって Strict CSP と同等の効果が得られる
  • CSP Level 2 / CSP Level 1 まで対応しているブラウザの場合
    • script-src-elem, script-src-attr は無視され script-src のみが採用される
    • CSP による実行制限はない(保護されない)

Classiの運用ポリシーとしてインラインイベントハンドラーで違反となるスクリプトについては極力インラインスクリプト形式にリファクタリングをするべきとしています。リファクタリングが妥当でない場合と JavaScript URL については unsafe-hashes で許可をすることも認めており、 script-src-elem と script-src-attr の適切な方にそれぞれ hash を追加していくことになります。最終的には以下のような設定例になっていきます。

Content-Security-Policy:
  object-src      'none';
  script-src      'report-sample' 'unsafe-inline' 'unsafe-eval' https: http:;
  script-src-elem 'report-sample' 'nonce-xxxxx' 'strict-dynamic' 'unsafe-hashes' 'sha256-xxxxxx';
  script-src-attr 'report-sample' 'unsafe-hashes' 'sha256-xxxxxx';
  base-uri        'none';
  report-uri      https://your-report-collector.example.com/;

まとめ

この CSP 運用を続けていく中でわかったことは以下の通りです。

  • オリジンの許可リストベースではレポート運用負荷が高くなり、CSP バイパスという問題も含めると費用対効果の観点では微妙と判断せざるを得ない
  • Strict CSP ベースで Google Tag Manager を利用している場合に、一部ブラウザでの違反レポートが抑止できない問題が発生しうる

CSP のディレクティブを整理することで、 CSP Level 3 に対応したブラウザに限定されますが適切な設定方法が見出せました。行き詰まった時は一度立ち止まって仕様や実装状況を整理することも大事だということを痛感しました。

現在、 Strict CSP をベースにした前述のポリシーで数週間運用をしていますが、違反レポートのノイズも少なく運用負荷も気になるレベルではありません。 Report Only で運用をしていましたが、一部ページでは Report Only も解除し、実際に不正なスクリプト実行には CSP によるブロックがかかる状態にも問題なく移行できています。 CSP の適用範囲を広げ、よりセキュリティ強度の高い設定を目指していくのが次の目標です。

今後も CSP に関わる運用が続いていきますので、また共有できる事例があれば紹介したいと思います。読んでいただきありがとうございました。

© 2020 Classi Corp.