AstroとVercelで手書きの自己検証CSPを実装する — 公式のsecurity.cspがオフライン機能を静かに壊していた
Astro公式のsecurity.cspはService Workerの登録を静かに壊すことがある。CIと実ブラウザの両方で検証する、手書きのCSPをAstro/Vercelに実装した記録。

目次
はじめに
このブログのURLをTrend MicroのSite Safety Centerに提出し、健全性のお墨付きを期待していた。だが2026年9月2日に返ってきたのは、サイトをDangerous、コンテンツタイプをDisease Vectorとする再分類結果だった。

コメントフォームもユーザーアップロードもサードパーティスクリプトも持たない静的ブログにとって、どちらのラベルも額面どおりの意味を持つわけではない。ただ、セキュリティヘッダーの欠落がスキャナーの評価に影響しうるという話は目にしていたし、このブログはContent-Security-Policyもなければ、HSTSもなにもない、ヘッダーを一切送っていない状態だった。それが今回の実際の原因かどうかにかかわらず、対応する価値のある課題だった。
依頼したのは、テンプレートの丸写しではなく、現時点のベストプラクティスを調べたうえでの丁寧な実装だった。得られたのは、動くContent-Security-Policyと標準的なセキュリティ強化ヘッダーだったが、想定していた経路とは違う道筋を通ることになった。Astro公式のCSP機能に、実際のバグがあったのだ。そのバグは、実ブラウザが先に検知していなければ、壊れたService Workerを全読者に配信していたはずのものだった。この記事は、その回り道と、そこから生まれた二段構えの検証方法について書く。Trend Microのスキャナーを実際に満足させられるかどうかは、まだ開いた問いのままだ。URLはまだ再提出していない。
まず試した方法: Astro公式のCSP機能
このサイトは完全に静的なAstroブログで、Vercelにホストしている。全ページが事前レンダリングされ、サードパーティスクリプトもCDNフォントも使わず、自サイト以外からは何も読み込まない。厳格なポリシーで固めるには、インターネット上でもっとも簡単な部類のサイトになるはずだった。
Astroには、このサイトが動いているバージョンよりずっと前から、安定した非実験的なCSPサポートがAstro 6.0以降で存在している。security.cspを有効にすると、Astroはビルド時に処理したすべてのスクリプトとスタイルをハッシュ化し、<meta>タグとして埋め込むか、あるいはVercelアダプターのstaticHeaders: trueオプションと組み合わせて、ページごとに実際のContent-Security-Policyヘッダーを書き出す。狙いはまさに静的サイトが求めるものだった。手作業でハッシュ一覧を管理する必要がなく、JSON-LDのようにページごとに内容が異なるものでも正しくハッシュ化できる。
このブログを構築したエージェントは、まずこれを試した。最初のビルドすら通らなかった。
Astro found issue(s) with your configuration:
! security.csp: Did not match union. > Expected type boolean | Directives script-src and style-src (including their -elem/-attr variants) are not allowed in security.csp.directives. Please use security.csp.scriptDirective and security.csp.styleDirective instead, scoping resources/hashes to the more specific directives with the kind option ("element" or "attribute").修正方法はエラーメッセージそのものに書かれていた。 トップレベルのdirectives配列に生の文字列を書くのではなく、kind: "attribute"を指定したstyleDirective.resourcesのエントリにする必要があった。ビルドは通るようになったが、新しい警告が出続けるようになった。
[WARN] [config] Shiki syntax highlighting uses inline styles that are notcompatible with Content Security Policy (CSP). Consider using Prism syntaxhighlighting instead, or disable CSP if Shiki is required.このブログのコードブロックはShikiでライト・ダーク両テーマを描画しており、それはつまり、ハイライトされたトークン一つひとつが2色分の値をstyle="--0:#24292e;--1:#e1e4e8"のような属性として持つということだ。これは設定ミスではなく、実際に避けられない仕組みだ。 Shikiのデュアルテーマ出力は色をどこかに置く必要があり、要素にCSSカスタムプロパティを持たせるのが、コードブロックを二重に描画せずに済ませる方法になっている。ビルド済みHTML125ファイルに、少なくとも1つはこれが含まれていた。修正は狭く、意図的なものにした。'unsafe-inline'をstyle-src-attrだけに限定し、実際の<style>要素に対するハッシュ検証には触れないようにした。
ブラウザが実際に検出したもの
CSPヘッダーは、ブラウザが実際に強制して初めて意味を持つ。だが、それをローカルで手軽に確認する方法がなかった。pnpm previewはビルド済みサイトを単純な静的ファイルサーバーで配信するだけで、ヘッダーは一切付与しない。アダプターのstaticHeaders: trueを有効にすると、Astroは<meta>タグの出力すら止めてしまうので、ノートパソコン上でテストできるものが何も残らなかった。エージェントは小さなスクリプトを書いた。Astroが実際に生成するヘッダーを読み取り、それを付けた状態でビルドを配信するローカルHTTPサーバーと、代表的な数ページに対してヘッドレスブラウザでコンソールを走査し、Content-Security-Policyに言及するものを探すパスだ。
最初の実行では、全ページで違反が見つかった。
Executing inline script violates the following Content Security Policydirective 'script-src ...'. Either the 'unsafe-inline' keyword, a hash('sha256-CUF1UsP1KF+rujto8WMYHvUjA3RVvt9YEmoxguvdzDs='), or a nonce('nonce-...') is required to enable inline execution. The action has beenblocked.ブロックされていたのは、Service Workerの登録スクリプトだった。 このサイトの全ページはオフライン閲覧のためにService Workerを登録しているが、Astroのハッシュ化はそれを一度もカバーしていなかった。実際に不一致だったハッシュを、実際にレンダリングされた<script>の内容のsha256と突き合わせて元のソースまで追跡すると、あるパターンが見えてきた。サイトのトップレベルレイアウトに直接書かれたスクリプトは正しくハッシュ化されるが、レイアウトがインポートする子コンポーネントに書かれた同種のis:inlineスクリプトはハッシュ化されない。検索ショートカット用のスクリプトとオフラインページ用のスクリプトが、まったく同じ問題を抱えていた。これはキャッシュの副作用ではなかった。 エージェントはビルドキャッシュ(node_modules/.astro、dist、.vercel/output)をすべて消去してゼロから再実行したが、同じスクリプトが同じようにハッシュ化されないまま返ってきた。
<style>ブロックの一部にも同じ問題があったが、こちらはもっと予測しづらいパターンだった。ある記事ページでは、2つのコンポーネントスタイルがハッシュ化されない一方、まったく同じページ内で隣り合う3つ目のスタイルは正しくハッシュ化されていた。3つとも同じ種類のスコープ付きコンポーネントスタイルで、差分から見ても、なぜ1つだけがカバーされ、残り2つがカバーされなかったのかは説明がつかなかった。
1つだけ、はっきり逆方向に解決した疑問があった。 JSON-LDブロック(<script type="application/ld+json">)はページごとに内容(タイトル、日付、説明文)が異なり、それこそがAstro公式のハッシュ機能を魅力的に見せていた理由でもあった。だがどのページでも、内容にかかわらず一度も違反として出てこなかった。script-srcは、type属性がJavaScriptのMIMEタイプでないスクリプト要素をそもそも制限しないようになっていて、これは最初から問題になりようがなかった。ただ、それを確信するには実ブラウザが必要だった。
代わりにポリシーを手書きする
全ページでオフライン閲覧を静かに壊すCSPは、CSPなしより悪い。 そこでエージェントは、Astroの自動ハッシュ化をあきらめて、vercel.jsonに直接ポリシーを手書きすることを提案した。私はその失敗が実際に示されてから、事前にではなく、方針転換を承認した。サイト全体でService Workerが壊れるリスクは、「手作業のハッシュ一覧が要らない」という利便性と引き換えにできるものではなかった。
手書きにすると信頼性の問題は解決するが、Astroのハッシュ機能が解決してくれていたはずの問題が再び開いてしまう。このサイトのスクリプトのうち一部(テーマ切り替え用、検索ショートカット用、オフラインページ用、そしてService Worker登録そのもの)は本当に固定なので、一度ハッシュ化してピン留めすればよい。だがAstroのデフォルトビルドは、十分小さいページ別スタイルシートやバンドル済みの小さな<script type="module">チャンクをHTMLに直接インライン化することがあり、どのチャンクがそれに該当するかはページが使うコンポーネントによってページごとに変わる。サイト全体で1つの固定ポリシーでは、ページごとに変わる値を追いかけられない。そこでastro.config.mjsのbuild.inlineStylesheets: 'never'とvite.build.assetsInlineLimit: 0によって、それらをすべて外部ファイルとして強制的に出力させることにした。あとはstyle-src 'self'とscript-src 'self'でこの部分を一律にカバーでき、この部分については手作業でハッシュ化するものが残らない。
当初の計画では、このサイトにある7つのインラインstyle=""属性は単純にCSSクラスへリファクタリングでき、'unsafe-inline'をまったく使わずにCSPの穴をふさげると見込んでいた。その前提は、きちんと数え直した時点で崩れた。 style={をgrepすると、さらに9件が見つかった。複数の記事の図版コンポーネントが、ループ内でデータ点ごとに1つの数式からSVG図形の不透明度を個別に設定していたのだ。連続的に計算される値を、少数のCSSクラスに変換する方法はない。そのためstyle-src-attr 'unsafe-inline'は手書きポリシーにも残ることになったが、今回は偶然見つかった穴ではなく、意図的でスコープを絞った判断としてのものだ。
このドメインでは他に何も運用していないし、その予定もない。これが判断をシンプルにした唯一の事実だった。トレードオフの全体像は次のとおり。
preloadなし | preloadあり | |
|---|---|---|
| HTTPSが強制されるタイミング | 訪問者が最初にHTTPSでアクセスした時点から | どのブラウザであっても、初回アクセスより前の、最初のリクエストから |
includeSubDomainsが適用される範囲 | 今後このドメインに | 削除が反映されるまで、未来に生まれるすべてのサブドメインに永続的に |
| 取り消す方法 | ヘッダーを変更または削除する | 削除を申請したうえで、大半のブラウザから消えるまで6〜12週間以上待つ |
ポリシーを陳腐化させないために
手書きのハッシュ一覧は、それを最新に保つ運用規律の分だけしか信頼できない。そしてこのプロジェクトのCLAUDE.md自身のルールは、スクリプトで検証できるところを運用規律だけに任せないというものだ。ここで起こりうる失敗は2種類あり、それぞれ別のチェックが必要になる。
1つ目の失敗モードは、狭く機械的だ。 4つの固定スクリプトのどれかが変更されたのに、編集した人がハッシュの再生成を忘れるというものだ。scripts/compute-csp-hashes.tsは実際にビルドされたHTMLを読む(ソースではなく、Astroがレンダリングした出力とバイト単位で一致させる必要があるため)。そして、現在のハッシュ集合をvercel.jsonに書き込むか(pnpm csp:build)、すでに正しいことを確認して、違っていれば非ゼロで終了する(pnpm csp:check)かのどちらかを行う。このチェックはビルドステップの直後にCIで実行するようにした。ビルド済みHTMLと比較する必要があるためだ。
もう1つの失敗モードは、固定のハッシュ一覧では絶対に捕まえられない種類のものだ。この記事を一度も読んでいない誰かが、将来、インラインのスクリプトやスタイルを持つ新しいMDXコンポーネントを追加するケースがそれにあたる。それを見つけられるのは、実際のポリシーを実際のページに対して強制する実ブラウザだけだ。 そのためにscripts/check-csp-sweep.tsは、vercel.jsonから実際に読み取ったヘッダーそのものを付けてビルド済みサイトを配信する(これによって、すでに実運用からずれたポリシーをテストしてしまうことがなくなる)。そして、公開済みの全記事を持っているすべての言語で、ユーティリティページも含めてヘッドレスChromiumで読み込む。pnpm csp:sweepはサイト全体をこの方法で確認し、pnpm csp:sweep <slug>は記事を1本だけ確認する。CIには組み込んでいない。このリポジトリには、図版がボックスからはみ出していないかを確認するPlaywrightベースのチェックという前例がすでにあり、その理由がそのまま当てはまる。どのワークフローもChromiumのバイナリをインストールしておらず、スクリプトやスタイル、コンポーネントに誰かが触れたときにだけ意味を持つチェックのために、そのコストを払う価値はない。
スイープ用のスクリプト自体も、動くようになったあとで一度修正が必要だった。最初の実行では、ローカルには存在しないVercel Analyticsのエンドポイントに対する違反が報告された。
Refused to execute script from '.../_vercel/insights/script.js' because itsMIME type ('') is not executable, and strict MIME type checking is enabled.これはCSP違反のように読めるが、実際は違う。 X-Content-Type-Options: nosniffが、Content-Typeヘッダーを持たない404レスポンスをスクリプトとして実行しようとする動きをブロックしていた。これも実ブラウザの保護機構ではあるが、狙っていたものとは別のものだった。コンソールメッセージのフィルターが、「Content Security Policy」だけを対象にするのではなく、「refused to」という一般的な文字列にマッチしていたため、両方を拾ってしまっていた。フィルターを絞り込み、ローカルサーバーの404レスポンスに正しいContent-Typeを持たせたことで、実際のチェックには手を入れずにこの誤検知を消すことができた。
検証: 今何が本番稼働していて、何がまだなのか
pnpm csp:sweepをサイト全体に対して実行すると、158ページを確認して違反ゼロだった。pnpm checkとpnpm buildもどちらもクリーンで、Service Workerがあらかじめキャッシュしておく本体は326KBから約445KBに増えた(以前インライン化されていたCSSとJSがすべて外部ファイルになり、その分プリキャッシュ対象になったためだ)が、それでも自前の500KB予算の範囲内だった。
デプロイ後の最初の実運用チェックも、それ自体が誤報だった。マージが反映された直後にcurl -sI https://oharu121.com/を実行すると、Content-Security-Policyヘッダーがまったく付いていない応答が返ってきて、しかもAge: 333が付いていた。これは、本番デプロイが実際に完了する前のCDNキャッシュヒットだった。 デプロイジョブの完了を待ってから、キャッシュを回避したURLで再リクエストすると、実際のものが見えた。
Content-Security-Policy: default-src 'self'; img-src 'self' data:; font-src'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors'none'; connect-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline';script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' ...Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: DENY記事ページでも、この静的サイトで唯一サーバーサイドで動く/api/likes/[slug]ルートでも、結果は同じだった。
まだ開いたままなのは、この回り道の出発点になった問いそのものだ。TrendMicroへのURL再提出はまだ行っていないので、これで「Dangerous」「Disease Vector」という判定が変わるかどうかは確認できていない。セキュリティヘッダーの欠落が自動スキャナーの評価に影響しうるというのはもっともらしい推測だが、それはあくまで推測であって、Trend Microが公表している診断根拠ではない。 もし分類が動かなかったとしても、これらのヘッダー自体はそれ単独で導入する価値があったものであり、単に今回の出発点だった問いへの答えのすべてではなかった、というだけのことになる。
まとめ
今回たどった道筋は、最初に想定していたものではなかった。Astroの安定した公式ドキュメント付きのsecurity.cspは、静的サイトにとっての正しい長期的な答えに見えた。だが、トップレベルのレイアウトの外にあるスクリプトについて、Service Workerの登録を静かに落としてしまうことを突き止めるには、フレームワーク自身のビルド出力ではなく、実際のヘッドレスブラウザが必要だった。 代わりに本番稼働しているポリシーは手書きのもので、サイト全体の固定ポリシーではハッシュ化できないものをAstroのビルドがインライン化しないよう強制し、失敗しうる2種類の経路それぞれに対して別々の方法で検証している。古くなった固定ハッシュには安価なCIスクリプトを、新しく生まれうる違反にはローカルの実ブラウザによるスイープを。Trend Microの「Dangerous」判定が変わるかどうかという最初の問いは、まだ開いたままだ。
参考リンク
- MDN: Content-Security-Policy
- Astro docs: Content Security Policy configuration reference
- withastro/astro issue #13996: experimentalStaticHeaders not working on Vercel
- withastro/astro issue #14301: Experimental CSP not working with Astro image components
- HSTS Preload List submission and removal, hstspreload.org



