jQuery や Bootstrap のようなライブラリーを、CDN の URL からそのまま読み込んでいるサイトは少なくありません。手軽で速い反面、その CDN 上のファイルが差し替えられてしまえば、自分のサイトで見知らぬコードが実行されることになります。これを防ぐための仕組みが integrity 属性、いわゆる SRI(Subresource Integrity/サブリソース完全性)です。この記事では、ハッシュ値の書き方から crossorigin="anonymous" が必要な理由、ハッシュの作り方、読み込めなくなったときの原因の切り分けまでを解説します。
目次
SRI は読み込んだファイルが改ざんされていないか検証する仕組み
外部の CDN からライブラリーを読み込むとき、ブラウザーは指定された URL のファイルを取得して、その中身をそのまま実行します。ファイルの内容が想定どおりかどうかは、まったく確認していません。
つまり、攻撃者が CDN のアカウントやサーバーを乗っ取り、配信されているファイルに悪意あるコードを1行足したとしたら、その CDN からファイルを読み込んでいるすべてのサイトでそのコードが動いてしまうということです。自分のサーバーがどれだけ堅牢でも、外部から読み込んでいるスクリプトは同じページ内で同じ権限で動くため、フォームの入力値を盗み取ることもできます。
SRI はこのリスクを「ファイルの中身のハッシュ値(暗号学的ダイジェスト)を照合する」という方法で軽減します。作者が期待するハッシュ値を integrity 属性に書いておくと、ブラウザーは取得したファイルから実際のハッシュ値を計算し、両者を比較します。一致すれば実行し、一致しなければそのファイルの読み込みを中止する——それが SRI の働きです。ファイルの中身が1バイトでも変わればハッシュ値は別の値になるので、差し替えや挿入を確実に検知できます。
基本の使い方は integrity と crossorigin の2つ
使い方はとてもシンプルで、いつもの <script> タグに属性を2つ足すだけです。
<script
src="https://cdn.example.com/lib/library-1.2.3.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
integrity に書いているのが、このファイルに期待するハッシュ値です。ブラウザーはスクリプトを実行する前にハッシュ値を照合し、一致しなければ実行しません。crossorigin="anonymous" は、別のオリジン(ドメイン)にあるファイルを検証するために必要な指定で、理由は後述します。
スタイルシートの場合も同じです。<link rel="stylesheet"> に同じ2つの属性を付けます。
<link
rel="stylesheet"
href="https://cdn.example.com/lib/library-1.2.3.min.css"
integrity="sha384-9ndCyUa6mHhx1nPBLtXVJPRlgvQdKAqLJlpu9SlqHiZQ6cJXVQmqcyWH1qxYzRr8"
crossorigin="anonymous">
ハッシュ値の書式と使えるアルゴリズム
integrity の値は、長い呪文のように見えますが、構造は単純です。「ハッシュアルゴリズム名」+「ハイフン」+「Base64 エンコードしたダイジェスト値」という3つの部分からできています。
先ほどの例の値を分解すると、sha384 がアルゴリズム名、それに続くハイフンより後ろの oqVuAfXRKap7… の部分が Base64 エンコードされたダイジェスト値です。
接頭辞として使えるアルゴリズムは次の3つです。SHA-1 や MD5 は使えません。
| 接頭辞 | 内容 |
|---|---|
sha256 | SHA-256 のダイジェスト。Base64 で 44 文字になる |
sha384 | SHA-384 のダイジェスト。Base64 で 64 文字。CDN が配布するスニペットで最もよく使われる |
sha512 | SHA-512 のダイジェスト。Base64 で 88 文字 |
どれを選んでも SRI としては有効に働きます。迷ったら、多くの CDN が採用していて強度と長さのバランスがよい sha384 にしておけば十分です。なお、Base64 の値には + や /、末尾の = が含まれることがありますが、これらもハッシュ値の一部なので削らずにそのままコピーしてください。
crossorigin=”anonymous” を付けなければならない理由
SRI を使ううえで、いちばん引っかかりやすいのがこの crossorigin 属性です。「ハッシュを照合するだけなのに、なぜ CORS の設定が必要なのか」と疑問に思うところですが、これはブラウザーがクロスオリジンのファイルの中身を読めないようにしているためです。
通常、別のドメインから読み込んだスクリプトやスタイルシートは、実行や適用はできても、その内容を JavaScript から読み取ることはできません(不透明なレスポンス、opaque response と呼ばれます)。同一オリジンポリシーによる制限で、これがあるおかげで悪意あるサイトが他サイトの内容を盗み見ることを防げています。しかし、ハッシュ値の計算はファイルの中身を読むことそのものなので、この状態では検証ができません。
そこで、CORS(オリジン間リソース共有)を使ってファイルを取得します。crossorigin 属性を付けるとリクエストが CORS モードになり、配信側が Access-Control-Allow-Origin ヘッダーで「このオリジンに内容を共有してよい」と明示していれば、ブラウザーは中身を扱えるようになり、ハッシュ値を照合できます。主要な CDN はこのヘッダーを付けて配信しているので、こちら側は属性を書くだけで済みます。
指定できる値は2つあります。
| 値 | 動作 |
|---|---|
anonymous | クッキーや認証情報を送らずに CORS リクエストを行う。公開 CDN からの読み込みはこれ |
use-credentials | クッキーなどの認証情報を含めて CORS リクエストを行う。認証が必要な配信元から取得するときに使う |
公開されている CDN のファイルに認証情報を送る必要はないので、実際に書くのはほぼ crossorigin="anonymous" の一択です。逆に、自サイトと同じオリジンにあるファイルなら crossorigin は不要です。同一オリジンの内容はもともと読み取れるため、integrity だけで検証されます。
integrity を指定できるのは script と link
integrity はすべての要素で使えるわけではありません。対応しているのは <script> 要素と、次の rel を持つ <link> 要素です。
| 要素 | 用途 |
|---|---|
<script src="…"> | 外部 JavaScript の読み込み |
<link rel="stylesheet"> | 外部スタイルシートの読み込み |
<link rel="preload"> | リソースの先読み |
<link rel="modulepreload"> | JavaScript モジュールの先読み |
<img> や <iframe>、<video> には integrity はありません。書いても単に無視される(不明な属性として扱われる)だけで、画像の改ざんを検知することはできません。SRI は「ページ内でコードとして動くもの」を守る仕組み、と理解しておくとよいでしょう。
なお、JavaScript から通信する fetch() にも同名の integrity オプションが用意されており、取得したレスポンスに対して同じ検証を行えます。
ハッシュ値を用意する3つの方法
CDN が配布しているスニペットをコピーする
いちばん確実で手間がないのはこれです。Bootstrap のような主要なライブラリーの公式サイトや、jsDelivr・cdnjs といった CDN のページでは、integrity と crossorigin まで含んだ <script> タグがコピー用に用意されています。自分で計算する必要はないので、まずはこれを探してください。
openssl コマンドで計算する
スニペットが提供されていないファイルや、自分でビルドしたファイルには、自分でハッシュ値を計算します。macOS や Linux なら OpenSSL が使えます。ダイジェストをバイナリーで出力し、それを Base64 に変換するという2段構えです。
# SHA-384 のダイジェストを Base64 で出力する
openssl dgst -sha384 -binary library-1.2.3.min.js | openssl base64 -A
# 出力例(この前に "sha384-" を付けて integrity に書く)
# oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
-binary はダイジェストを16進数の文字列ではなくバイナリーで出力するオプション、-A は Base64 を改行せず1行で出力するオプションです。どちらも省くと integrity に使える形になりません。出力された文字列の先頭に sha384- を付ければ、そのまま属性値として使えます。
ハッシュ値は実際に配信されるファイルそのものから計算する必要があるので、CDN 上のファイルを対象にするなら、まずその URL をダウンロードしてから計算します。
# CDN のファイルを取得して、そのままハッシュ値を計算する
curl -sL https://cdn.example.com/lib/library-1.2.3.min.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
オンラインのハッシュ生成ツールを使う
コマンドラインを使わない方法もあります。SRI Hash Generator(srihash.org)のようなオンラインツールに CDN の URL を入力すると、integrity と crossorigin が入った <script> タグを組み立ててくれます。数個のファイルを手作業で設定するだけなら、これが手軽です。
複数のハッシュ値を並べて指定できる
integrity 属性には、ハッシュ値をホワイトスペース(スペース)で区切って複数書けます。この場合、リソースはそのうちのいずれかに一致すれば読み込まれます。
<!-- スペース区切りで2つ指定。どちらかに一致すれば読み込まれる -->
<script
src="https://cdn.example.com/lib/library-1.2.3.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
sha512-Qm2xoUiVBiBnP5wRKZ9Bl2RQ8Kk1YrLTIsXfBLdvJ5xkVQ0f7pMTmVnUcJ3rGyWQKt6ZbHu2eXmA9nDcFvSqLw=="
crossorigin="anonymous"></script>
使いどころは2つあります。1つはアルゴリズムの移行です。同じファイルに対して sha384 と sha512 の両方を書いておけば、片方のアルゴリズムに対応していない環境でも検証が働きます。
もう1つは複数バージョンの許容です。デプロイの過渡期などで、同じ URL から旧版と新版のどちらが返ってくるか確定しない場面では、両方のハッシュ値を並べておけばどちらでも読み込めます。ただし、これは「許容するファイルを増やす」ことなので、必要な期間だけに留めるのが安全です。
検証に失敗したときのブラウザーの動き
ハッシュ値が一致しなかった場合、ブラウザーはそのスクリプトを実行せず、スタイルシートを適用しません。取得そのものがネットワークエラーとして扱われ、コンソールには次のようなエラーが表示されます(Chrome の場合)。
Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/lib/library-1.2.3.min.js' with computed SHA-384 integrity
'C7fLYXk3W…'. The resource has been blocked.
このメッセージにはブラウザーが実際に計算したハッシュ値が含まれています。改ざんを疑う前に、まずはこの値と自分が書いた値を見比べてください。単なる書き間違いや、対象ファイルの取り違えであることも多いためです。
重要なのは、ブロックされたスクリプトの機能はまったく動かないという点です。SRI は「安全でないコードを実行するより、実行しないほうがよい」という考え方の仕組みなので、検証に失敗したときは何も読み込まれません。ライブラリーがサイトの表示や操作の中核を担っているなら、読み込みに失敗したときの備えも考えておきましょう。
失敗時には対象の要素で error イベントが発生するので、それを検知して自サーバーに置いたコピーへ切り替える、という手もあります。
<script
src="https://cdn.example.com/lib/library-1.2.3.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
<script>
// 読み込めていなければ、自サーバーのコピーを同期的に読み込む
if (!window.MyLib) {
document.write(
'<script src="/js/library-1.2.3.min.js">\x3C/script>'
);
}
</script>
ただし、フォールバック先のコピーが古かったり管理されていなかったりすると、それ自体が別の問題になります。そもそも重要なライブラリーは CDN に頼らず自サーバーに置く(セルフホストする)ほうが、SRI の管理もフォールバックの心配も不要になり、結果的に安全で単純です。CDN と SRI は「外部の便利さを使いたいが、丸ごと信頼はしない」ための組み合わせだと考えてください。
integrity を付けたらファイルが読み込まれなくなったとき
integrity を追加した途端にサイトの JavaScript が動かなくなった、というのは SRI でよくある事故です。原因はだいたい次のどれかに当てはまります。
URL のバージョンが固定されていない
最も多く、そして最も厄介なのがこれです。@latest や @1、/latest/ のように、時間が経つと中身が変わる URL に integrity を書いてはいけません。ライブラリー側がパッチ版を出した瞬間に配信内容が変わり、ハッシュ値が合わなくなって、それまで動いていたサイトが突然壊れます。しかも自分は何も変更していないので、原因に気付くまでに時間がかかります。
<!-- 悪い例:中身が更新されるとハッシュ値が合わなくなる -->
<script src="https://cdn.example.com/npm/library@latest/dist/library.min.js"
integrity="sha384-…" crossorigin="anonymous"></script>
<!-- 良い例:バージョンを固定した URL に指定する -->
<script src="https://cdn.example.com/npm/library@1.2.3/dist/library.min.js"
integrity="sha384-…" crossorigin="anonymous"></script>
SRI を使うなら、URL はパッチバージョンまで含めて固定するのが前提条件です。バージョンを上げるときは、URL と integrity の値を必ずセットで更新してください。
ハッシュ値を計算したファイルが違う
ハッシュ値はファイルの中身に完全に依存するので、対象を1つ間違えるだけで一致しません。ありがちなのは、圧縮版(.min.js)と非圧縮版を取り違えているケース、バージョン違いのファイルから計算してしまったケース、コピーしたハッシュ値の末尾が欠けているケースです。
エラーメッセージに出ている「computed」の値と、自分が書いた値を先頭から数文字だけでも見比べれば、取り違えかどうかはすぐ分かります。心当たりがなければ、先ほどの curl と openssl を組み合わせたコマンドで、その URL から実際に返ってくる内容のハッシュ値を計算し直すのが確実です。
crossorigin が無い、または CORS ヘッダーが返っていない
クロスオリジンのファイルに crossorigin="anonymous" を書き忘れると、ブラウザーは中身を読めないため検証できず、リソースはブロックされます。ハッシュ値そのものは正しいのに読み込まれないときは、まずこの属性の有無を確認してください。
属性を付けているのに失敗する場合は、配信側が Access-Control-Allow-Origin ヘッダーを返していない可能性があります。主要な CDN は問題ありませんが、自社の別ドメインや小規模な配信元では設定されていないことがあります。レスポンスヘッダーはコマンドラインでも確認できます。
# Access-Control-Allow-Origin が返っているか確認する
curl -sI https://cdn.example.com/lib/library-1.2.3.min.js | grep -i access-control
配信の途中でファイルが書き換えられている
見落としやすいのが、配信経路でファイルが加工されているケースです。サーバーやプロキシ、あるいはサイト高速化系のプラグインが JavaScript や CSS を自動で最小化・結合していると、ブラウザーが受け取る中身は元のファイルと別物になり、当然ハッシュ値も一致しません。自分のサーバーのファイルに integrity を付けたのに失敗する、というときはこの線を疑ってください。
まとめ
integrity 属性(SRI)は、CDN などから読み込んだファイルのハッシュ値を照合し、改ざんされていれば読み込みを中止する仕組みです。値は sha384-… のように「アルゴリズム名+ハイフン+Base64 のダイジェスト」という書式で、使えるアルゴリズムは sha256 / sha384 / sha512 の3つ。クロスオリジンのファイルには、内容を検証できるようにするため crossorigin="anonymous" を併記します。
指定できるのは <script> と <link>(stylesheet / preload / modulepreload)で、画像には使えません。ハッシュ値は CDN 提供のスニペットをコピーするか、openssl dgst -sha384 -binary と openssl base64 -A で自分で作れます。導入するときはバージョンを固定した URL に対して指定することを忘れないでください。可変 URL に付けると、ある日いきなりサイトが壊れます。