外部CDNからJavaScriptやCSSを読み込むコードを眺めていると、ときどき次のような記述に出くわす。
<script
src="https://cdn.example.com/library.min.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
integrity 属性がなにやらセキュリティに関係しているらしいことは、僕もなんとなく知っていた。ところが、いざ考えはじめると疑問が次々に湧いてくる。HTTPSで配信しているなら、それで十分ではないのか。npmでインストールする場合にも要るのだろうか。
CSPとの違いや、Next.jsやViteでビルドしたアプリにとっての意味も気になる。そして極めつけに、そんなに大事そうなのに、普段のWeb開発ではとんと見かけない。いったいどういうわけなのか。
そこで、Subresource Integrity(以下、SRI)の仕様と、実際に防げる攻撃、過去のインシデント、現代のビルド環境との関係をひととおり調べてみた。先に言ってしまうと、SRIは万能のセキュリティ機能ではない。もっとも、外部CDNから固定バージョンのJavaScriptやCSSを読み込むなら、今でも比較的安上がりで効き目の大きい対策である。
厄介なのは、中身がしょっちゅう変わる第三者スクリプトだ。Google Tag Manager、広告、分析ツール、A/Bテストツールの類には、SRIを当てにくい。皮肉なことに、リスクの高いスクリプトほどSRIを付けづらいのである。
仕組みとしてのSRIは、昔の割符によく似ている。HTMLがハッシュという片割れを持ち、届いたファイルがもう片方としてぴたりと合うかをブラウザが確かめる。というわけで、「SRIは必ず付けるべし」と決めつけるのではなく、この割符がどういう構成で役に立つのかを順に考えていく。
ハッシュで中身を縛る仕組み
そもそもSRIは、ブラウザが取得したJavaScriptやCSSの中身を、HTMLに書かれたハッシュ値と照合する仕組みである。たとえば次のようなscriptタグがあったとする。
<script
src="https://cdn.example.com/library.min.js"
integrity="sha384-BASE64_HASH"
crossorigin="anonymous"
></script>
ブラウザはまず src に指定されたファイルを取得し、その中身からハッシュ値を計算する。次に integrity 属性の値と突き合わせ、一致したときだけJavaScriptを実行する。一致しなければ、読み込みそのものを失敗させる。
ここで肝心なのは、不一致でも警告だけ出してしれっと実行する、といった甘い対応をしないことだ。リソースはネットワークエラーとして扱われ、JavaScriptなら実行されず、CSSなら適用されない。つまりSRIとは、「このファイルは本当に事前に承認した中身なのか」をブラウザ側で確かめる検証なのである。W3CのSRI仕様でも、取得したリソースが想定外に改変されていないことをユーザーエージェントが検証する仕組みとして定義されている。
実際の使い方も見ておく。Bootstrapなどを外部CDNから読み込む場合は、次のような形になる。
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
integrity="sha384-..."
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
対応している主なハッシュアルゴリズムは、SHA-256、SHA-384、SHA-512の三つだ。実務ではSHA-384がよく使われている。ローカルのファイルからハッシュを作るなら、たとえば次のように計算すればよい。
openssl dgst -sha384 -binary library.min.js | openssl base64 -A
出力された文字列の頭に sha384- を付けて、integrity 属性に指定する。
integrity="sha384-生成されたBase64文字列"
crossoriginが要る理由
外部オリジンのリソースにSRIを使う場合、たいていはcrossorigin="anonymous" も一緒に指定する。
<script
src="https://cdn.example.com/library.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
というのも、SRI付きのクロスオリジンリクエストでは、ブラウザがCORSを使ってレスポンスを取得するからだ。当然、配信元のCDNも次のようなCORSレスポンスヘッダーを返してくれなければならない。
Access-Control-Allow-Origin: *
なぜわざわざそんな手間をかけるのか。クロスオリジンのopaque responseに好き勝手なハッシュ照合を許すと、攻撃者が一致・不一致を手がかりに別オリジンの中身を推測できてしまうおそれがあるからだ。したがって、CDN側がCORSに対応していなければ、正しいハッシュを書いてもSRI付きのリソースは読み込めないことがある。
HTTPSとは守る場所が違う
最初に引っかかったのはここだった。HTTPSは通信経路を暗号化し、途中での盗聴や改ざんを防いでくれる。それならSRIなど要らないのではないか、と。
ところが、両者が守っているものは微妙にずれている。HTTPSが保証するのは、主に通信相手が証明書に対応したホストであることだ。加えて、通信の途中で中身を盗み見られにくく、書き換えられにくいことも保証してくれる。一方のSRIが検証するのは、取得したファイルが開発者の事前に承認した中身と一致すること、ただそれだけである。
たとえば、正規のCDNそのものが侵害され、正規のHTTPS接続を通して悪意あるJavaScriptを返してきたとする。HTTPSの観点では、これはなにも問題がない。通信相手は正規のCDNだし、経路もきちんと暗号化されている。届いたファイルの中身だけが、悪意あるものにすり替わっているのだ。
ここでSRIを指定していれば、割符が合わないことにブラウザが気づき、実行を止められる。要するに、HTTPSは通信経路を信頼するための仕組みで、SRIは届いた中身を検証するための仕組みである。両者は代わりになり合う関係ではなく、補い合う関係というわけだ。
URLは正しいが中身が違うとき
SRIが効くのは、基本的に「URLは正しいのに、そのURLから返ってくる中身が変わっている」という状況である。代表的なのは、外部CDN上のファイルが攻撃者に書き換えられた場合だ。ハッシュが一致しなくなるので、ブラウザは実行を止める。第三者サービスのサーバーやアカウントが乗っ取られ、同じURLから不正なJavaScriptが配信された場合も、HTML側のハッシュが更新されていない限り検知できる。
なお、相手は悪意ある攻撃だけとは限らない。CDNの設定ミスやキャッシュの不整合で別のファイルが返ってきた場合にも、SRIは律儀に読み込みを止める。URL上でバージョンを固定していても、安心はできない。https://cdn.example.com/library/1.2.3/library.min.js のようなURLでも、配信元があとから同じURLのファイルを差し替える可能性はゼロではないからだ。
SRIはURLではなく実際のファイルの中身を固定するので、こうした変更も見逃さない。DNS攻撃やCDNのキャッシュ汚染で別のサーバーや別のコンテンツへ誘導された場合も、返ってきたファイルが事前のハッシュと違えばブロックされる。もっとも、HTMLまで同時に書き換えられてしまう状況では防げない。
脅威ごとに整理すると次のようになる。
| 脅威・事故 | SRIの有効性 | 理由 |
|---|---|---|
| CDN上のJavaScript改ざん | 高い | 内容が変わればハッシュ不一致になる |
| 外部配信事業者の侵害 | 高い | 同じURLから悪意あるファイルが返っても検知できる |
| CDNの誤配信 | 高い | 想定外の内容は原因を問わずブロックされる |
| 固定バージョンURLの差し替え | 高い | URLではなく内容を固定できる |
| CDNキャッシュ汚染 | 条件付き | 配信内容だけが変わる場合は有効 |
| DNSハイジャック | 条件付き | HTMLが安全なままで、外部リソースだけが差し替えられる場合は有効 |
| 中間者攻撃 | 条件付き | HTTPSが第一防御。内容が変わった場合の追加検証になる |
| npmパッケージ侵害 | 原則無効 | ブラウザに届く前のビルド工程で侵害されている |
| HTML自体の改ざん | 無効 | src と integrity を両方書き換えられる |
| XSS | 無効 | 外部ファイルの整合性検証とは別の問題 |
| CSRF | 無効 | リクエスト偽造を防ぐ仕組みではない |
割符ごと偽造されるとき
SRIの責任範囲をつかむには、むしろ防げないものを眺めた方が早い。まず、HTMLそのものの改ざんには手も足も出ない。ハッシュ値はHTMLに書かれているのだから、HTMLを書き換えられる攻撃者は、src と integrity をまとめて差し替えればよいだけの話である。
<script
src="https://attacker.example/malware.js"
integrity="sha384-攻撃者が計算した正しいハッシュ"
></script>
SRIはHTMLを信頼の起点にしている。割符の片割れを預けている場所が落ちれば、相手は両方を好きなように作り直せるのだ。
次に、正式に公開された悪意あるバージョンも防げない。依存ライブラリの開発元が侵害され、悪意あるバージョンがnpmやCDNに堂々と公開されたとする。開発者がそれを中身も確かめずに取ってきてハッシュを更新すれば、SRIは何事もなかったかのように通す。SRIが確かめているのは「承認した中身と一致するか」であって、「そのコードが安全か」ではないからだ。
同じ理屈で、ua-parser-js や event-stream のようにnpmパッケージ自体に悪意あるコードが仕込まれたケースでも、SRIは原則として役に立たない。悪意あるコードはnpmから取得され、ビルド成果物の一部として自社のJavaScriptに取り込まれる。ブラウザから見れば、それはれっきとした自社ファイルである。
この領域で要るのは別の対策だ。lockfile、npm audit、DependabotやRenovate、パッケージ署名やprovenanceがまず挙がる。依存関係のレビュー、CI/CD環境の保護、ビルド成果物の検証も欠かせない。
最後に、XSSやCSRFも守備範囲外である。SRIは外部リソースの中身を検証する機能に過ぎない。ユーザー入力からスクリプトが注入されるXSSや、不正なリクエストを送らせるCSRFを直接防ぐものではないのだ。XSS対策の主役は、CSP、出力エスケープ、Trusted Typesあたりになる。
lockfileやcontent hashとの違い
package-lock.json や pnpm-lock.yaml にも、依存パッケージのintegrity情報が記録されている。npmの package-lock.json なら、たとえば次のような値だ。
{
"integrity": "sha512-..."
}
見た目はSRIとそっくりだが、検証するタイミングがまるで違う。lockfileのintegrityは、npmパッケージのインストール時に、取ってきたtarballが想定どおりかを確かめる。守っているのは、npm registryから開発環境やCIへ届くまでの区間である。
一方ブラウザのSRIは、本番環境でユーザーがJavaScriptやCSSを取得する時点で検証する。守っているのは、CDNやWebサーバーからユーザーのブラウザへ届くまでの区間だ。ハッシュの形式こそ似ているが、守っている工程が違うのである。
| 仕組み | 検証タイミング | 主な目的 |
|---|---|---|
| lockfile integrity | パッケージインストール時 | パッケージ取得の整合性・再現性 |
| SRI | ブラウザのリソース取得時 | 配信ファイルの内容検証 |
したがって「lockfileがあるからSRIは要らない」とは必ずしも言えない。もっとも、npmで取ってきたライブラリを自社でバンドルし、自社ドメインから配信する構成なら事情が変わる。外部CDNを直接読む場合に比べて、SRIの上乗せ効果は小さくなる。
似たような勘違いをしやすいのが、content hash付きのファイル名だ。ViteやWebpackでは、ビルドしたファイルにassets/index-a8f45c2d.js のような名前が付くことがある。一見これも中身を検証しているように見えるが、主な目的はキャッシュ制御である。中身が変われば index-a8f45c2d.js が index-b71d903a.js に変わるので、ブラウザに新しいファイルを取りにいかせやすくなる。
ところが、ブラウザはファイル名のハッシュ部分と実際の中身を照合しているわけではない。攻撃者やCDNの設定ミスで同じURLから別の中身が返ってきても、ファイル名だけでは気づけないのだ。content hashは、キャッシュ無効化とバージョン管理のための道具に過ぎない。ブラウザによる中身の検証を担うのは、あくまでSRIである。
CSPとの分業
SRIとよく並べて語られるのがContent Security Policy(CSP)だ。CSPは、ページがどの配信元からスクリプトや画像などを読み込めるかを制限する。
Content-Security-Policy: script-src 'self' https://cdn.example.com
この例では、自社オリジンと cdn.example.com からのスクリプトを許可している。ところが cdn.example.com 自体が侵害されると、話は変わってくる。同じホストから悪意あるコードが配信されても、ホストベースのCSPだけではすんなり通してしまうのだ。そこで出番になるのがSRIで、許可したホストから取ってきたファイルの中身が期待どおりかを確かめる。
つまりCSPは「どこから読み込めるか」を制御し、SRIは「読み込んだ中身がなにか」を検証する。役割が違うぶん、組み合わせると効く。高いセキュリティが求められる画面なら、HTTPS、CSP、SRIに加えて、外部スクリプトの削減、Trusted Types、依存関係の監視まで揃えておきたい。
ついでに触れておくと、かつてCSPにはrequire-sri-for というディレクティブがあった。
Content-Security-Policy: require-sri-for script style
scriptやstyleにSRIを必須化しようという意図のものだった。しかし標準化やブラウザ実装上の問題から、現在は廃止・非推奨の扱いになっている。新しく実装するなら、これに頼るのは避けるべきだ。
その代わりというわけでもないが、2025年以降は新しいIntegrity-Policy ヘッダーの実装が進んでいる。
Integrity-Policy: blocked-destinations=(script)
Integrity-Policy は、対象のリソースに整合性検証を要求したり、違反をReporting API経由で集めたりするための新しい仕組みだ。script向けの対応は、Chrome 138やFirefox 145以降で進んでいる。SRI自体は2016年にW3C Recommendationとなった技術だが、2026年時点でも関連仕様の拡張は続いている。「古くて終わった技術」と片付けるより、コアは成熟し、周辺の強制・監視機能が足されつつある段階と見るのが正確なところである。
過去のインシデントで効いたのか
では、実際の事件でSRIは役に立ったのだろうか。
2024年には、Polyfill.ioから悪意あるJavaScriptが配信される騒ぎが起きた。外部のJavaScript配信サービスが侵害・悪用され、利用サイトがコードを一行も変えていないのに、訪問者のブラウザで不正なJavaScriptが実行されうる状態になったのである。典型的なサプライチェーン攻撃だ。対象のファイルが固定の中身で、あらかじめSRIを設定できていたなら、不正な差し替えに気づけた可能性はある。
ところがPolyfill.ioは、User-Agentや要求された機能に応じて返す中身を変える動的なサービスだった。同じURLでも利用者ごとに中身が変わるのだから、固定のハッシュを指定するSRIとはそもそも相性が悪い。固定ファイルの差し替えには強いが、動的に中身が変わるサービスには当てにくい。この一件は、SRIの効き目と限界をいっぺんに見せてくれた格好である。
Ticketmasterの事例では、決済ページで使われていた第三者のチャットサービスのJavaScriptに不正なコードが挿し込まれ、顧客データが漏えいした。対象が外部URLから読み込まれる固定ファイルで、HTML側が改ざんされていなかったのなら、SRIで防げた可能性はある。もっとも、実際の読み込み方式や更新方法によってはSRIを適用できなかったかもしれない。「SRIがあれば確実に防げた」とまでは言い切れないのだ。
British Airwaysでは、決済情報を盗み取るJavaScriptがWebサイトに注入された。いわゆるMagecartの被害である。このケースはサイト側のスクリプトやHTMLそのものが侵害された可能性があり、そうなると攻撃者はSRIも一緒に書き換えられる。SRIだけで防げたと考えるのは難しい。
ua-parser-js や event-stream は、npmパッケージ自体に悪意あるコードが公開された事例だ。侵害されたコードはビルド時にアプリへ取り込まれるので、ブラウザのSRIの守備範囲からは外れる。
こうして並べてみると、同じ「サプライチェーン攻撃」でもSRIが効くものと効かないものがはっきり分かれる。外部CDN上で実行時にファイルが差し替えられる類には、SRIが効く。npmやCI、ビルド工程が侵害される類は、原則として防げない。
付けやすい場所、付けにくい場所
SRIが一番分かりやすく効くのは、外部CDNから固定バージョンのライブラリを読む場合である。Bootstrap、jQuery、Prism.js、Highlight.js、Font Awesome、Reactの固定UMDビルドといった類のものだ。URLとファイルの中身が固定されていれば、更新時にハッシュを書き換えるだけで回る。手間に対して得るものが大きいので、原則として付けておくのが合理的な判断になる。
ビルド環境のない静的サイトでも、外部CDNのタグにintegrity を足すだけで導入できる。GitHub Pages、静的なLP、CMSで管理されたページ、ドキュメントサイト、更新頻度の低い企業サイトあたりとは殊に相性がよい。
WordPressでも、scriptやstyleを出力するフックを使えば integrity と crossorigin を追加できる。外部CDNの固定ファイルを使っているなら、導入する価値はある。ただし、テーマやプラグインがURLを勝手に更新するようだと、ハッシュとの整合性に気を配る必要が出てくる。
管理画面や決済画面のように、管理者権限や個人情報を扱うページでは、第三者のJavaScriptそのものをできるだけ減らすべきだ。それでも外部リソースを読み込まざるをえないなら、SRIの優先度は一気に上がる。とりわけ決済画面では、第三者スクリプトがたった一つ侵害されただけで、入力された情報を根こそぎ持っていかれかねない。
翻って、Google Tag Manager、広告、分析ツールのようなスクリプトは、同じURLでも中身が頻繁に更新される。ここにSRIを付けると、更新のたびにハッシュが合わずにスクリプトが止まる。ハッシュを書き換え続ける専任の係を24時間体制で雇うなら話は別だが、そんな求人に応募する奇特な人はまずいない。そのため、Google Analytics、広告タグ、タグマネージャー、A/BテストツールなどではSRIを現実的に運用できないことが多い。
これはなかなか構造的な問題である。最もリスクの高い第三者スクリプトほど、中身がしょっちゅう変わるせいでSRIを当てにくいのだから。
この領域で頼りになるのは、SRI以外の手立てだ。不要なタグを削除し、タグを追加できる権限を絞り、CSPで通信先を制限する。iframeやsandboxで隔離し、第三者スクリプトの変更を監視する。そもそも決済画面では読み込まない、という手もある。
チャット、カスタマーサポート、パーソナライズ、A/Bテストのような動的なウィジェットも同様で、ユーザーや設定ごとに配信される中身が変わることがある。そうなると、単一のハッシュ値では中身を固定できない。
Webpack Module Federationもいささか厄介だ。remoteEntry.js やそれに続くチャンクを、実行時に動的に読み込むからである。SRIの適用自体はできるものの、remoteEntryのバージョン固定、ハッシュ値の配布、動的にscriptを挿入するときの integrity の設定、後続チャンクのハッシュ管理、ホスト側とremote側のデプロイの整合性と、用意すべき仕組みがずらりと並ぶ。単純なCDN読み込みに比べると、運用の難易度はかなり高い。
ES Modulesと圧縮の落とし穴
ES Moduleの場合、エントリーポイントにだけSRIを付ければ済むのかというと、そうはいかない。次のようなモジュールがあったとする。
<script
type="module"
src="/assets/main.js"
integrity="sha384-..."
></script>
main.js がさらに別のモジュールをimportしていても、エントリーポイントのSRIが依存モジュール全体に自動で伝播するわけではない。
import "./feature.js";
import("./lazy.js");
検証されるのは main.js の初回取得だけだ。子モジュールまで同じハッシュで面倒を見てくれるわけではない。依存モジュールにも整合性情報を持たせる方法としては、import mapの integrity キーが標準化されている。
<script type="importmap">
{
"imports": {
"app": "/assets/main.js"
},
"integrity": {
"/assets/main.js": "sha384-...",
"/assets/feature.js": "sha384-...",
"/assets/lazy.js": "sha384-..."
}
}
</script>
とはいえ、実際のビルド成果物には大量のチャンクが含まれる。Vite、Webpack、Next.jsなどで全チャンクにSRIを導入するなら、ビルド時にハッシュを自動生成する仕組みはほぼ必須だ。手作業でやろうものなら、ハッシュを貼り終える前に次のリリースが来る。
一方、圧縮については心配がいらない。gzipやBrotliによるHTTP圧縮は、通常SRIと両立する。ブラウザは、転送時の圧縮を展開したあとの中身を使うからだ。同じJavaScriptをgzipで送ろうがBrotliで送ろうが、通常はハッシュを変える必要はない。
問題になるのは、配信の途中でJavaScriptやCSSの中身そのものをいじる処理である。CDN上での自動minify、JavaScriptの書き換え、User-Agent別のコード変換、地域別のファイル差し替え、A/Bテストによる中身の変更、同一URLでの個別最適化といったものだ。最終的にブラウザが受け取るコードが変われば、ハッシュは当然一致しない。CloudflareのRocket Loaderのように、scriptタグやJavaScriptの読み込み方を変える機能とも相性問題が起こりうる。
そういうわけで、SRIを使うファイルには条件を課しておく方が安全である。同じURLなら常に同じ中身を返し、CDN側でコードを変換しないこと。immutableなファイルとして配信し、URLにはバージョンかcontent hashを含めておきたい。
可用性という代償
SRIはハッシュが一致しなければリソースを実行しない。セキュリティ上は正しい振る舞いだが、運用の側から見れば障害の種にもなる。
たとえば、新しいHTMLには新しいハッシュが書かれているのに、CDNのエッジには古いJavaScriptが鎮座している、というデプロイの食い違いが起きたとする。この場合、ハッシュが合わずにアプリが起動しないおそれがある。逆に、古いHTMLが新しいJavaScriptを参照しても同じことが起きる。
殊に注意がいる構成はいくつもある。Blue-Green Deployment、ローリングデプロイ、HTMLと静的アセットを別々にデプロイする構成、HTMLのキャッシュが長い構成がまず挙がる。CDNキャッシュのパージに時間がかかる構成や、動的チャンクが大量にある構成も同様だ。SRIを入れるなら、セキュリティだけでなく、デプロイの原子性やキャッシュ戦略まで考えておく必要があるのだ。
CDNの読み込みに失敗したときに、自社ホスト版へ切り替えるという手もある。
<script>
function loadLocalLibrary() {
const script = document.createElement("script");
script.src = "/assets/library.min.js";
script.integrity = "sha384-LOCAL_HASH";
script.crossOrigin = "anonymous";
document.head.appendChild(script);
}
</script>
<script
src="https://cdn.example.com/library.min.js"
integrity="sha384-CDN_HASH"
crossorigin="anonymous"
onerror="loadLocalLibrary()"
></script>
ただし、フォールバックを増やせばそのぶん悩みも増える。CDNの障害とSRIの不一致を見分けにくくなるし、CSPでフォールバック先も許可しなければならない。管理すべきハッシュは複数になり、障害解析は込み入り、フォールバック先がいつの間にか古くなっている可能性もある。固定のライブラリを数本読み込む程度なら実用的だが、大規模SPAの大量のチャンクにまで適用するのは現実的ではない。
React、Vite、Next.jsではどうするか
React自体にはSRIを管理する機能はない。ReactをCDNのUMDビルドで直接読み込むなら、外部CDN利用の一つとしてSRIを付ける価値がある。一方、npmでインストールしてViteやWebpackでバンドルするなら、SRIはビルドツールの側で面倒を見ることになる。
Webpackではwebpack-subresource-integrity が代表格だ。動的チャンクも含めてSRIを生成できる。ただし、WebpackのバージョンやHTML生成プラグインとの互換性は確かめておく必要がある。
Vite本体には、汎用的なSRIの自動付与が標準機能として十分に組み込まれているとは言いがたい。頼りになるのはコミュニティプラグインで、候補には vite-plugin-manifest-sri や vite-plugin-sri-gen、その他のSRI生成プラグインがある。
もっとも、守備範囲はプラグインごとにまちまちだ。HTMLに直接 integrity を足すもの、manifestにハッシュを足すもの、modulepreloadにも対応するもの、動的チャンクまで面倒を見るものがある。導入するときは、メンテナンスの状況と、なにを生成するのかを確かめておきたい。
Next.jsには、App Router向けにSRIを利用したhash-based CSPの実験的サポートがある。experimentalな機能なので、ビルド方式やルーター、静的生成との組み合わせには制限がつく。そもそも、Next.jsが自前で配信する通常のチャンクすべてにSRIを付ける価値は、外部CDNの固定ライブラリほどはっきりしない。HTMLとアセットが同じデプロイ基盤・同じ信頼境界にあるなら、HTMLを書き換えられる攻撃者は割符の両方を一度に作り直せるからだ。
かといって、無意味というわけでもない。静的アセットを別のCDNから配信する、エッジでの変換を挟む、厳格なCSPと連動させる、といった構成では意味が出てくる。
構成ごとの判断
ここまでの話を構成ごとにまとめると、次の表のようになる。
| 構成 | 推奨度 | 判断 |
|---|---|---|
| 外部CDNから固定バージョンのJS/CSSを読む | 高い | SRIの代表的な適用先。原則付ける |
外部CDNからlatestを読む | 低い | SRI以前にバージョンを固定した方がよい |
| 静的HTMLでBootstrapをCDNから読む | 高い | 導入が簡単で効果も分かりやすい |
| WordPressで固定CDNライブラリを読む | 中〜高 | ハッシュ更新を管理できるなら有効 |
| 決済画面で固定外部スクリプトを読む | 高い | 外部スクリプト削減が第一。必要ならSRI |
| npmで取得して自社ビルドするSPA | 低〜中 | 外部CDN利用時より追加効果は小さい |
| 自社CDNでcontent hash付きファイルを配信 | 低〜中 | 信頼境界や配信経路が分かれる場合に意味がある |
| Google Tag Managerや広告タグ | 低い | 内容が頻繁に変わり固定できない |
| 分析・A/Bテストツール | 低い | 動的配信とSRIの相性が悪い |
| Module Federationのremote | 低〜中 | 実装可能だがハッシュ配布とデプロイ管理が難しい |
| Next.jsの通常チャンク | 中 | 配信構成とCSP要件次第 |
| 管理画面・個人情報入力画面 | 高い | 外部スクリプトを減らし、残る固定資産にはSRI |
では結局、外部CDNにはSRIを付けるべきなのか。今回調べた範囲での答えは、条件つきのイエスである。外部オリジンからJavaScriptかCSSを読み込み、URLが特定のバージョンに固定され、同じURLから常に同じ中身が返る。そのうえでCDNがCORSに対応し、更新時にハッシュも書き換えられるなら、原則として付けておけばよい。
たとえば次のような読み込みを見かけたら、SRI云々の前にまずバージョンの固定を考えた方がいい。
<script src="https://cdn.example.com/library/latest.js"></script>
バージョンを固定したうえでSRIを付ければ、URLと中身の両方を固定できる。
<script
src="https://cdn.example.com/library/1.2.3/library.min.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
逆に、中身が頻繁に変わる分析スクリプト、タグマネージャー、広告配信タグには、SRIを無理に付けない方がいい。利用者ごとに中身が変わるウィジェット、動的に解決されるremote、User-Agentごとに違うコードを返すサービスも同じだ。そういうものには、別の防御を考える必要がある。
なお、SRIは単独で入れて終わりという代物ではない。実務での優先順位としては、まず不要な第三者スクリプトを削ることから始めるのが現実的だ。一番安全なのは、そもそも読み込まないことなのだから。なかでも管理画面、決済画面、個人情報の入力画面では、マーケティングタグやチャットツールを本当に読み込む必要があるのか見直すべきである。
次に、HTTPSとCSPを整える。HTTPSは前提として、CSPで読み込み元や通信先を絞れば、不正なスクリプトの注入やデータの持ち出しは難しくなる。そのうえで、固定の外部リソースにSRIを付ける。ここはSRIを適用しやすく、効果も大きい領域だ。
さらに、lockfile、依存の更新、監査、CI/CDの保護によって、npmの依存関係とビルド工程を守る。これはSRIとは別に必要になる。最後に、SRIを使えない動的な第三者スクリプトは、iframe、sandbox、CSP、変更の監視、権限の管理などを組み合わせて監視・隔離する。
固定ファイルという前提
調べる前の僕にとって、SRIは「外部CDNのタグにハッシュを付けるだけの、ちょっと古いセキュリティ機能」くらいの印象だった。ところが実際には、SRIが解こうとしている問題は今もしっかり残っている。
そもそも、WebページでJavaScriptを外部から読み込むのは、その配信元に自分のページと同等の権限を渡すのに近い行為である。外部スクリプトはページ上でDOMを読み、フォームの入力を読み、Cookie以外のクライアントデータにアクセスできる。APIにリクエストを送り、外部のサーバーにデータを送り、追加のスクリプトまで読み込める。だからこそ、「有名なCDNだから信頼する」で済ませず、「このファイルのこの中身だけを許可する」というSRIの考え方は理にかなっているのだ。
その反面、現代のWebでリスクが高いGoogle Tag Manager、広告、分析、A/Bテスト、チャットなどは、中身が頻繁に変わるためにSRIを当てにくい。SRIがあまり使われていないのは、技術として無意味だからではない。現代の第三者スクリプトが、「固定ファイルを読み込む」というSRIの前提から遠ざかってしまったからだ、と僕は見ている。
以上、SRIが防げるものと防げないもの、そしてどこに付けるべきかを調べてきた。SRIの価値は、信頼している配信元から取ってきたリソースが、事前に承認した中身と本当に一致しているかをブラウザ側で確かめられる点に尽きる。CDNの改ざん、外部配信元の侵害、誤配信、固定URLの差し替えには効く。一方で、HTMLそのものの改ざん、XSS、npmパッケージの侵害、CI/CDの侵害、悪意ある正式リリースまでは防げない。
付け方の方針もまとめておく。外部CDNの固定バージョンのJS/CSSには原則として付け、npmで自社バンドルする一般的なSPAでは優先度を下げる。管理画面や決済画面ではまず第三者スクリプトを減らし、SRIを使えない動的スクリプトにはCSP・隔離・監視で備える。そして、SRIをセキュリティ対策全体の代わりにはしない。
すべてのWebサイトに絶対必要な機能ではないが、付けやすい場所では今でも安く確かな効果が得られる防御である。もっとも、割符は持っているだけでは意味がない。大事なのは、どの配信経路を信頼し、どの工程の改ざんを防ごうとしているのか。それを自分で説明できることなのだ。
参考資料
- W3C Subresource Integrity
- WHATWG HTML Standard
- WHATWG Fetch Standard
- W3C Content Security Policy Level 3
- MDN: Subresource Integrity
- MDN: integrity属性
- MDN: import maps
- MDN: modulepreload
- OWASP Third Party JavaScript Management Cheat Sheet
- web.dev: Why HTTPS matters
- Webpack Production Guide
- Vite Build Options
- Next.js Content Security Policy Guide
- Angular CLI ng build
- Laravel Vite
- Vue CLI Configuration Reference
- Cloudflare: Polyfill.io links replacement
- Codecov Bash Uploader Security Update
- GitHub Advisory: ua-parser-js
- GitHub Advisory: flatmap-stream
- ICO: British Airways Penalty Notice