# AstroとVercelで手書きの自己検証CSPを実装する — 公式のsecurity.cspがオフライン機能を静かに壊していた

> Astro公式のsecurity.cspはService Workerの登録を静かに壊すことがある。CIと実ブラウザの両方で検証する、手書きのCSPをAstro/Vercelに実装した記録。

- Source: https://oharu121.com/ja/blog/astro-vercel-security-csp-service-worker-hash/
- Published: 2026-09-03T00:26:46+09:00
- Tags: Astro, Vercel, Service Worker, セキュリティ

---
## はじめに

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

*Image: oharu121.comに対するTrend Micro Site Safety Centerの再分類結果。2026年9月2日11:29 AM UTC付けで、New Safety Rating: DangerousおよびNew Content Type: Disease Vectorと表示されている*

*再分類結果。このサイトは以前は「Unrated」「Untested」だった。*

コメントフォームもユーザーアップロードもサードパーティスクリプトも持たない静的ブログにとって、どちらのラベルも額面どおりの意味を持つわけではない。ただ、セキュリティヘッダーの欠落がスキャナーの評価に影響しうるという話は目にしていたし、このブログは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 not
compatible with Content Security Policy (CSP). Consider using Prism syntax
highlighting 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 Policy
directive '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 been
blocked.
```

**ブロックされていたのは、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タイプでないスクリプト要素をそもそも制限しない**ようになっていて、これは最初から問題になりようがなかった。ただ、それを確信するには実ブラウザが必要だった。

*Figure — CspRequestFlow: Content-Security-Policyヘッダーが制約するのは、ページが到着したあとブラウザが何を実行・描画するかだけであり、そこに至るまでにビルドツールが何を正しくハッシュ化できたかについては何も語らない。*

## 代わりにポリシーを手書きする

**全ページでオフライン閲覧を静かに壊す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'`は手書きポリシーにも残ることになったが、**今回は偶然見つかった穴ではなく、意図的でスコープを絞った判断としてのものだ。**

> **ちなみに**
>
> **HSTSの`preload`フラグだけは、このプロジェクト全体でエージェントではなく私自身が直接下した判断だ。** 一度提出すると`includeSubDomains`はそのドメインが将来持つことになるすべてのサブドメインに適用され、取り消しても大半のブラウザから消えるまでに6〜12週間かかる。

このドメインでは他に何も運用していないし、その予定もない。これが判断をシンプルにした唯一の事実だった。トレードオフの全体像は次のとおり。

| | `preload`なし | `preload`あり |
| --- | --- | --- |
| HTTPSが強制されるタイミング | 訪問者が最初にHTTPSでアクセスした時点から | どのブラウザであっても、初回アクセスより前の、最初のリクエストから |
| `includeSubDomains`が適用される範囲 | 今後このドメインに | 削除が反映されるまで、未来に生まれるすべてのサブドメインに永続的に |
| 取り消す方法 | ヘッダーを変更または削除する | 削除を申請したうえで、大半のブラウザから消えるまで6〜12週間以上待つ |

## ポリシーを陳腐化させないために

手書きのハッシュ一覧は、それを最新に保つ運用規律の分だけしか信頼できない。そしてこのプロジェクトのCLAUDE.md自身のルールは、スクリプトで検証できるところを運用規律だけに任せないというものだ。**ここで起こりうる失敗は2種類あり、それぞれ別のチェックが必要になる。**

*Figure — TwoWayCspCheck: 古くなった固定ハッシュと、まったく新しい違反は失敗のしかたが異なるため、それぞれに別のチェックを用意している。*

**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 its
MIME 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; preload
X-Content-Type-Options: nosniff
X-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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [Astro docs: Content Security Policy configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
- [withastro/astro issue #13996: experimentalStaticHeaders not working on Vercel](https://github.com/withastro/astro/issues/13996)
- [withastro/astro issue #14301: Experimental CSP not working with Astro image components](https://github.com/withastro/astro/issues/14301)
- [HSTS Preload List submission and removal, hstspreload.org](https://hstspreload.org/)
