# Intro

create-react-app などで作られた Single Page Application(SPA) に CSP Level 3 (strict-dynamic + hash-source) を適用するための webpack plugin を作った。

# Content-Security-Policy (CSP) と Single Page Application

CSP については既に多くのドキュメントや記事があるため、詳しくはそれらを参照してほしい。

CSP Level 3 では strict-dynamic が使えるようになったので、比較的容易に導入できる。 Safari >= 15.4 でも CSP Level 3 対応が入ったため、古いブラウザを考慮しない環境では導入しやすくなっただろう。

ヘッダや HTML を動的に生成できるウェブアプリケーションフレームワークでは nonce-source な CSP を導入しやすい一方で、SPA ではそれと比べると次のような理由で少し導入が難しい。

理由1. nonce-source な CSP の導入が難しい

素朴にビルドした assets を配信している SPA では、レスポンスごとに nonce を生成することができないため。配信サーバーでレスポンスを書き換えることができる場合、その機能(e.g. nginx の sub_filter など)を使って nonce を差し込むことはできるが、アプリケーションと CSP のポリシーが分離してしまうため、次のような工夫をしなければ CSP を適用した状態での開発がやりにくい。

理由2. Report-Only から始めることができない / 一部ディレクティブが使えない

Content-Security-Policy-Report-Only ヘッダや report-uri などの一部ディレクティブは meta タグでサポートされていない。なので、ヘッダを動的に生成できない SPA では、前段の HTTP サーバーで CSP ヘッダを追加する必要がある。
こちらも「理由1」と同様に CSP のポリシーがアプリケーションと分離してしまうため、工夫をしなければ CSP を適用した状態での開発がやりにくい。 また、CSP ヘッダの設定とアプリケーションがリポジトリとして分離している場合、hash-source なディレクティブを適用するなら、CI / CD 上でハッシュを計算して別リポジトリにイベントとして送信して更新をかける必要がある。

理由3. meta タグに CSP を設定する場合は webpack plugin が必要

理由1,2 のように、環境によっては HTTP レスポンスヘッダに設定することが難しいので、meta タグに設定しようにも、ビルド時に inline script や inline style に関してはハッシュを計算しなければならない。
ビルド時にそれを自動で行うには webpack plugin のような仕組みを利用する必要がある。grep / sed などを駆使して実現もできるだろうが、開発はやりにくいだろう。
しかし、その仕組みさえあれば CSP が適用された状態での開発やポリシーの変更・管理は行いやすい。



まとめると、SPA では CSP をヘッダに付与する場合は、インフラレイヤでの設定が必要になるので、環境によっては導入のハードルが高い。一方で meta タグだと「Report-Only で始めることができない」「webpack plugin などで動的にポリシを生成できる仕組みがが必要」という課題がある。

# 既存の webpack plugin

以上のように、SPA では導入の敷居が高いのだが、一枚の HTML で bundle した一枚の JavaScript を読み込んでいるだけなので、strict-dynamic であれば CSP 違反はほとんど発生しない気もする(フレームワークによっては unsafe-eval を許容する、などの必要は出てきそうだが)。
なので Report-Only で始めることができないのは許容するとし、 meta タグに CSP を設定するための webpack plugin をいくつか調べた。

google/stric-csp-html-webpack-plugin

Strict-CSP を適用するための html-webpack-plugin。
https://csp.withgoogle.com/docs/faq.html#static-content に書いてあるアプローチを実装しており、外部スクリプトをロードしている場合はインラインスクリプトでそれを動的に読み込むように変換する(loader script と呼ばれている模様)ことで、strict-dynamic 対応を容易にしている。
同じリポジトリにある strict-csp パッケージを使えば、webpack を使っていない環境でも同様に変換できる。
あくまで Strict-CSP を適用するためのパッケージなので、他のディレクティブを追加することはできない。また、experimental なパッケージであるので一部挙動がおかしいところもある(プロダクションビルド時にハッシュが異なるなど)。

slackhq/csp-html-webpack-plugin

strict-csp-html-webpack-plugin と比較して、細かくディレクティブを設定できる。ただし、strict-csp-html-webpack-plugin のように動的に外部スクリプトを読み込むようには変換せず、外部スクリプトの integrity 属性も設定しないので、hash-source な CSP の適用が難しい。
https://github.com/slackhq/csp-html-webpack-plugin/issues/50https://github.com/slackhq/csp-html-webpack-plugin/pull/87 にそれぞれ Issue / Pull Request があるが、対応しないらしい。
リポジトリの Last commit date を見ても積極的にメンテするつもりはないのだろうと思う。

# @mrtc0/csp-html-webpack-plugin を作った

上記を Fork してメンテしているものもあるだろうが、google/strict-csp が採用しているような loader script を使うアプローチで、細かくディレクティブを設定できるものがほしいので作ってみることにした(webpack-plugin というものに興味があったのもある)。

名前空間を追加して @mrtc0/csp-html-webpack-plugin というパッケージで公開している。slackhq/csp-html-webpack-plugin と混同されることも懸念としてあるが、他に良い名前もなかったので諦めた。
README に書いてあるように webpack の設定に追加すれば使用できる。なので、create-react-app では eject するなり、craco などを使うなりする必要がある。
実装は google/strict-csp-html-webpack-plugin を参考にしつつ、ディレクティブを設定できたりなどのオプショナルな機能を追加したり、細かいバグを直したりしている。

(ちなみに) レポートを受け取りたい場合

meta タグでもレポートを収集したい場合、 SecurityPolicyViolationEvent イベントを受け取れば良さそう。ただし、読み込まれるタイミングと CSP 違反のタイミングによってはもちろん拾えないので注意。

document.addEventListener("securitypolicyviolation", (e) => {
  Sentry.captureException(new Error(e.type), { extra: {
    event: e,
    blockedURI: e.blockedURI,
    violatedDirective: e.violatedDirective,
    originalPolicy: e.originalPolicy,
    activeElement: e.target.activeElement.outerHTML,
  }});
})

References