セキュリティ 2026年9月27日

SRIを1行足せば済むと思っていた。CDNのAlpine.jsは「3.x.x」指定で半年に13回中身が変わり、115ページで読み込んで使っていたのは1ページだけだった

XECIN サプライチェーンJavaScript依存関係技術検証

先週、問い合わせページの文言を直すついでに、7月から宿題にしていた SRI(Subresource Integrity)に手を付けました。

7月にCSPを入れたとき、Alpine.js を jsDelivr から読んでいる以上、CSP で https://cdn.jsdelivr.net を許可するのは「そのCDNが配るものは何でも信じる」と宣言するのと同じだ、という話を書きました。2024年に polyfill.io のドメインが別の持ち主に渡り、同じURLから別物のスクリプトが配られた件を思い出すと、ここは放っておけません。SRI はまさにそこを塞ぐ仕組みで、script タグにファイルのハッシュを書いておけば、ブラウザが受け取った中身と照合して、違えば実行を拒否してくれます。

やることは integrity 属性を1行足すだけ。見積もりは10分でした。

結果から言うと、この見積もりは3つの点で外れました。

まず棚卸しから:115ページで読み込み、使っていたのは1ページ

ハッシュを計算する前に、外から読み込んでいるスクリプトを全部洗い出しました。外部スクリプトの対策は、付け足す前に「そもそも要るのか」を見るのが先だと考えています。ソースだけでなく、ビルド成果物の側でも数えます。

# ソース側: 外部から読み込んでいる script タグ
grep -rn '<script[^>]*src="https://' src/layouts/
# 実行結果(測定IDは伏せています):
# src/layouts/BaseLayout.astro:64:<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
# src/layouts/BaseLayout.astro:67:<script is:inline async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"

# ビルド成果物側: 何ページに載っていて、何ページで使っているか
find dist -name '*.html' | wc -l                              # 118
grep -rl 'alpinejs@3.x.x' dist --include=*.html | wc -l       # 115
grep -rl 'x-data=' dist --include=*.html                      # dist/contact/index.html
grep -rho 'integrity="[^"]*"' dist --include=*.html | wc -l   # 0

118ページ中115ページが Alpine.js を読み込んでいて、x-data を持っているのは問い合わせページの1ページだけでした。残りの3ページは広告用のLPで、別のレイアウトを使っているので最初から読み込んでいません。

つまり114ページは、何もしないサードパーティのスクリプトを毎回ダウンロードして実行していたことになります。共通レイアウトに1行で入れたものが、そのまま全ページに広がっていた、という単純な話なんですよね。SRI を付けるかどうか以前に、CDNを信じなければならないページの数を115から1に減らせる。棚卸しの時点で、これが一番効く手だと分かりました。

もう一つ気づいたのは、この Alpine.js が package.json にも package-lock.json にも出てこないことです。7月に npm audit で依存を数えたときの841個に、Alpine は入っていません。CDNから読むライブラリは、npm の依存管理からも脆弱性の監査からも見えない場所にいます。

想定外その1:URLが「3.x.x」だった

棚卸しの結果に書いてあるとおり、読み込み元のURLは alpinejs@3.x.x でした。バージョン3系の最新を配ってください、という指定です。jsDelivr は、実際にどの版を返したかをレスポンスヘッダに出してくれるので、版を固定したURLと並べて見ました。

# いま使っている「3.x.x」のURL
curl -sI https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js \
  | grep -iE '^(x-jsd-version|cache-control):'
# 実行結果(2026-09-27):
# cache-control: public, max-age=604800, s-maxage=43200
# x-jsd-version: 3.17.4

# 版を固定したURL
curl -sI https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js \
  | grep -iE '^(x-jsd-version|cache-control):'
# 実行結果:
# Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
# x-jsd-version: 3.17.4

# SRI に書くハッシュ(sha384)
curl -s https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
# 実行結果:
# 5/joNqFnRyVWzXp99bHot6RHG+EksGp+USSgZwPar7T9SD9PKKER37n/8bXBAZGd

今日返ってきたのは 3.17.4 で、npm に公開されたのは9月21日。6日前に出た版で、私たちの誰も中身を見ていないものが、全ページで動いていたわけです。

キャッシュの指定も違います。版を固定したURLは1年間 immutable なのに対して、「3.x.x」の方はブラウザで7日間(604,800秒)、CDNのエッジで12時間です。新しい版が出ると、エッジは半日ほどで新しい方を配り始める一方、以前に来た人のブラウザには最長7日間古い版が残ります。「うちのサイトはいま何版で動いているか」という質問に、1つの答えが無い状態でした。

では、どのくらいの頻度で中身が変わっているのか。npm のレジストリから公開日を取って数えました。

import json, urllib.request

meta = json.load(urllib.request.urlopen("https://registry.npmjs.org/alpinejs"))
releases = sorted(
    (ts[:10], ver) for ver, ts in meta["time"].items()
    if ver.startswith("3.") and "-" not in ver
)
since = [r for r in releases if r[0] >= "2026-03-26"]  # 本番デプロイを始めた日
print(len(since), since[0], since[-1])
print(sorted({ver.rsplit(".", 1)[0] for _, ver in since}))
# 実行結果:
# 13 ('2026-03-27', '3.15.9') ('2026-09-21', '3.17.4')
# ['3.15', '3.16', '3.17']

サイトを公開してからの半年で13回。8月10日以降の7週間に限ると9回で、ほぼ毎週です。その間にマイナーバージョンも 3.15 から 3.17 まで2段上がっていて、どちらも私たちが何も判断しないまま本番に入っていました。

ドキュメントにはこう書いてあるけど、SRI が保証するのは「受け取ったファイルが、ハッシュを計算したときのファイルと1バイトも違わない」ことです。改ざんと正規のリリースを区別する仕組みではありません。もし最初の見積もりどおり、今日のハッシュを「3.x.x」のURLにそのまま足していたら、この半年で13回、次のリリースのたびに問い合わせフォームが止まっていた計算になります。SRI は、版を固定したURLとセットでしか成り立ちません。

想定外その2:止まると、フォームごと消える

では、止まったときに実際に何が起きるのか。ここはブラウザで確かめました。

ビルド成果物をまるごと作業用ディレクトリにコピーし、問い合わせページの script タグだけを3通りに書き換えて、ローカルの簡易サーバから Chrome で開き、コンソールとDOMの状態を読んでいます。本番には一切触れていません。

ケースscript タグに書いたものChrome の判定フォームの状態
A:先週のハッシュ3.x.x のURL、1つ前の版(3.17.3)のハッシュ、crossorigin ありブロック。計算したハッシュと一致するものが無い、というエラー高さ 0px、送信ボタン 0個
B:crossorigin 忘れ3.x.x のURL、今日の正しいハッシュ、crossorigin なしブロック。CORS のリクエストでないので検証できない、というエラー高さ 0px、送信ボタン 0個
C:版固定3.17.4 のURL、正しいハッシュ、crossorigin=“anonymous”実行(Alpine 3.17.4)。転送 20,758 バイト高さ 497px、送信ボタン 1個

ケースAは「先週ハッシュを計算して貼り、今週CDNの版が上がった」状況の再現です。3.17.3 と 3.17.4 のファイルは 55,886 バイトと 55,891 バイトで、5バイトしか違いませんが、ハッシュはまったく別物になります。

ケースBは、正しいハッシュを書いても crossorigin を付け忘れると止まる、という確認です。別オリジンのファイルを検証するにはCORSで取得する必要があり、属性が無いとブラウザは「検証できないので実行しない」側に倒します。integrity だけをコピペすると、ここで引っかかります。

正直なところ、一番ひやっとしたのは右端の列でした。

止まったとき、フォームは崩れて表示されるのではなく、丸ごと消えます。問い合わせフォームは、Alpine が初期化するまで中身がちらつかないように x-cloak を付けていて、コンポーネントのCSSに [x-cloak] { display: none !important; } があります。この属性を外すのは Alpine 自身なので、Alpine が読み込まれない限りフォームは永遠に非表示のままです。訪問者から見えるのは見出しの下の空白だけで、エラー表示も出ません。コンソールを開かない限り、誰も気づかない壊れ方です。

SRI は、配信元が改ざんされたときの挙動を「そのまま実行する」から「止める」に変えてくれます。セキュリティとしては正しい変化ですが、止まる先が問い合わせページだと、「止まる」はそのまま「問い合わせが来なくなる」を意味します。改ざんのリスクを、可用性のリスクと交換している。この交換を意識せずに全ページへハッシュを貼っていたら、と思うと怖いところです。

どこまでやるか:4つの選択肢と費用

選択肢を並べて比べました。

選択肢版を決めるのは配信元が改ざんされたら版を上げるときの手間月額
1. 以前の状態(3.x.x、integrity なし)CDN115ページでそのまま実行されるなし(勝手に上がる)0円
2. 3.x.x のまま integrity だけ足すCDN止まる新しい版が出るたびにフォームが消える(半年で13回)0円
3. 版固定+integrity+crossorigin を、使うページだけに自分たち1ページで止まるURLとハッシュの2か所を書き換える0円(jsDelivr)
4. npm で入れて自前で配信package-lock.jsonCDNの経路そのものが無くなる通常の依存更新と npm audit に乗るCloudFront の無料利用枠内(試算)

選択肢2は、表にすると選択肢にならないことがはっきりします。

費用はどれもほぼゼロです。jsDelivr は無料ですし、自前で配信する場合も、Alpine.js は圧縮後 20,458 バイト(Brotli)です。仮に問い合わせページを月1万人が初めて開いても約205MBで、CloudFront の無料利用枠(毎月1TB)に収まります。枠を使い切っていたとしても、日本向けの転送単価 0.114USD/GB で月0.03ドル弱、数円です。

つまり、ここはお金で決まる話ではありません。運用を考えると、決め手は「版が変わるタイミングを誰が決めるか」と「その依存がどの台帳に載るか」だと考えています。マネージドのCDNに任せる利点は、配信の速さと手間の無さです。ただ、手間が無いということは、判断の機会も無いということでした。

最終的に目指すのは選択肢4です。ただ、npm への移し替えは依存ファイルとビルドの両方に手が入るので、まずはレイアウトと問い合わせページだけで完結する選択肢3を先に入れて、戻しやすい形で一段階進めることにしました。ここは好みが分かれるところですが、一度に2つ変えると、問題が起きたときにどちらの変更が原因か切り分けにくくなるので。

もう一つ、同じく115ページに載っている Google タグ(gtag.js)には SRI を付けられません。Google が同じURLのまま中身を随時更新するためで、ハッシュを固定した瞬間に計測が止まります。こちらはCSPでドメインを許可し、配信元を信頼するという判断を明示的に残す扱いにしました。棚卸しの価値は、SRI を付けられるものと付けられないものを分けて、付けられないものについて「信頼している」と言語化できることにもあると思っています。

ローカルで組んだ形

レイアウトにプロパティを1つ足して、Alpine を使うページだけが宣言する形にしました。

---
// src/layouts/BaseLayout.astro(抜粋)
interface Props {
  title: string;
  description?: string;
  useAlpine?: boolean; // Alpine を使うページだけ true
}
const { title, description, useAlpine = false } = Astro.props;
---
{useAlpine && (
  <script
    is:inline
    defer
    src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"
    integrity="sha384-5/joNqFnRyVWzXp99bHot6RHG+EksGp+USSgZwPar7T9SD9PKKER37n/8bXBAZGd"
    crossorigin="anonymous"
  ></script>
)}

<!-- src/pages/contact.astro 側 -->
<BaseLayout title="無料相談・お問い合わせ | XECIN" useAlpine>

ローカルのビルドでは118ページがそのまま生成され、Alpine を読み込むのは問い合わせページの1ページだけになりました。出力された script タグは、上の表のケースCとまったく同じ形です。ほかのページに Alpine の属性(x-cloak やクリックの割り当て)が残っていないことも、成果物を検索して確かめています。

最後に、同じことがまた起きないように、ビルド成果物を検査するスクリプトを書きました。

# check_external_scripts.py: dist の外部 script タグを検査する
import pathlib, re, sys

ALLOW_NO_SRI = ("https://www.googletagmanager.com/",)  # 中身が随時変わるので SRI を付けられない
FLOATING = re.compile(r"@(latest|\d+\.x(\.x)?|\d+)(/|$)")

ng = []
for page in sorted(pathlib.Path("dist").rglob("*.html")):
    html = page.read_text(encoding="utf-8")
    for tag in re.findall(r'<script\b[^>]*\bsrc="https?://[^"]+"[^>]*>', html):
        src = re.search(r'src="([^"]+)"', tag).group(1)
        if src.startswith(ALLOW_NO_SRI):
            continue
        if "integrity=" not in tag or "crossorigin=" not in tag:
            ng.append((page, src, "integrity/crossorigin なし"))
        elif FLOATING.search(src):
            ng.append((page, src, "版が固定されていない"))

print(f"NG {len(ng)} 件")
sys.exit(1 if ng else 0)
# 実行結果: 以前の dist では NG 115 件(終了コード 1)、組み替えた後は NG 0 件(終了コード 0)

このスクリプトには改善の余地があります。見ているのは integrity と crossorigin が「書いてあるか」と、URLが版を固定しているかだけで、書いてあるハッシュが今の配信物と一致するかは確かめていません。表のケースAのように古いハッシュが入っていても、この検査は通ってしまいます。Google タグをドメインごと素通しにしている許可リストも、あとから別のスクリプトが同じドメインに増えたときの穴になり得ます。HTML を正規表現で読んでいるので、属性の書き方が変わると取りこぼす可能性もあります。今はローカルで手で回している段階で、CIにはまだ組み込めていません。

振り返って

10分の見積もりが外れた理由は、3つでした。URLが版を固定していなかったこと、crossorigin が要ること、そして止まったときにフォームごと消える作りだったこと。どれも、ハッシュの計算そのものとは関係のないところです。

今思えば、SRI は「ハッシュを1行足す作業」ではなく、「いつ版が変わるかの決定権を、CDNから自分たちに取り戻す作業」でした。取り戻した以上、版を上げる判断と手間も自分たちの側に来ます。

今後は、次の順で進めていきたいと考えています。

まず、Alpine.js を npm で入れてビルドに同梱し、package-lock.json と npm audit の管理下に置くこと。そうすればCSPから cdn.jsdelivr.net を外せます。次に、検査スクリプトを「ハッシュが実際の配信物と一致するか」まで確かめる形にして、CIに組み込むこと。それから、Alpine が何秒たっても初期化されなかったときに、フォームの代わりにメールアドレスを表示するような逃げ道を用意すること。止まることを前提にするなら、止まったあとの画面まで設計しておく必要があります。

外部スクリプトの棚卸しも、年に一度ではなく、四半期に一度くらいの頻度で回していくつもりです。