Notes / Web技術

View Transitionsとstrict CSPの不可避な衝突。Astro security.cspの限界

AstroのClientRouterはランタイムでdata:URI scriptを生成して前ページのinline scriptを再評価する設計。これがstrict CSPと正面衝突します。Astro security.csp + strict-dynamicを試したが頓挫した経緯と、現実的な選択肢の整理。

CSP Astro View Transitions security

CSPを段階導入する記事を書いた直後、自社サイトで実装中に壁にぶつかりました。AstroのView Transitions (<ClientRouter />) を有効化したまま、'unsafe-inline'data:も許可しないstrict CSPにしたい。けれどこの組み合わせは、技術的に詰みます。

Astro公式ドキュメントが明示的に「CSP integrationはClientRouter非対応」と書いていて、回避策は実質3つしかありません。

何が起きるか

Report-Onlyで違反を集めていると、/contact/ページから1件のCSP違反が届きました。

{
  "effectiveDirective": "script-src-elem",
  "blockedURL": "data",
  "sourceFile": "ClientRouter.astro_astro_type_script_index_0_lang.js",
  "documentURL": "https://staging.example.com/contact/"
}

blockedURL: "data"はChromeがdata:スキームURIをプライバシー上の理由で省略表記したもの。ClientRouterがランタイムで動的に<script src="data:text/javascript,..."を作成して前ページのinline scriptを再評価しています。

CSPの'unsafe-inline'<script>foo</script>形式のインラインタグを許可しますが、<script src="data:...">形式のdata URIスクリプトは別カテゴリで未許可です。

試したこと: Astro security.csp + strict-dynamic

Astro 6には安定機能としてsecurity.cspがあります。inline scriptのSHA hashを自動生成して<meta http-equiv="content-security-policy">タグに埋める仕組み。

// astro.config.mjs
export default defineConfig({
  security: {
    csp: {
      algorithm: 'SHA-256',
      scriptDirective: {
        strictDynamic: true,
      },
      directives: [
        "default-src 'self'",
        // ...
      ],
    },
  },
});

strict-dynamicを有効にすると、ハッシュ済みスクリプトから動的にロードされたスクリプトも信頼の連鎖に入ります。これでClientRouterのdata: URIスクリプトも救えるはず。

buildしてPlaywrightを流しました。56テスト中6件失敗。エラーは衝撃的でした。

Loading the script 'http://127.0.0.1:4321/_astro/WireframeBackground.js'
violates the following Content Security Policy directive:
"script-src 'self' 'sha256-...' 'strict-dynamic'".
Note that 'strict-dynamic' is present, so host-based allowlisting is disabled.

strict-dynamicを入れた瞬間、'self'が無効化されてAstro自身が生成した外部スクリプト(<script src="/_astro/...">)が全部ブロック。サイトが完全に動かなくなりました。

strict-dynamicの仕様を読み直す

CSP仕様によると、strict-dynamicは:

If 'strict-dynamic' is present, only scripts with valid nonce/hash are allowed, AND scripts dynamically added by such trusted scripts. Allowlist-based sources like 'self', https:, and URL allowlists are IGNORED.

つまりstrict-dynamicを入れた瞬間、'self'もURLallowlistも無効化される。信頼の起点はnonceかhashだけ。

Astroが生成する<script type="module" src="/_astro/file.js">はHTMLパーサーが直接ロードするので、信頼の連鎖の対象外。動的にロードされたスクリプトではないので、strict-dynamicの保護下に入りません。

回避するには、外部スクリプトタグ全部にnonceかintegrity(SRI)を付与する必要がありますが、Astro 6のsecurity.cspはそこまで自動化していません。

Astro公式ドキュメントの明記

調査中にAstroのsecurity.cspセクションを精読し直して気付きました。

Astro’s CSP implementation has limitations: it does not support external scripts/styles out of the box (though hashes can be provided), nor Astro’s <ClientRouter /> view transitions.

Astro自身が「ClientRouter非対応」と明記している。設計上の制約として宣言済みです。

つまりAstro標準の機能だけでstrict CSP + View Transitionsを両立させる道は、現時点ではありません。

残された3つの選択肢

内容View TransitionsCSPの穴工数
Ascript-srcdata:追加維持既存'unsafe-inline'に加えて1個5分
BCF Pages Functions middlewareでnonce注入維持なし2〜3h + 単一障害点
C<ClientRouter />削除喪失なし5分

A: data:を許可する

script-src 'self' 'unsafe-inline' data:。最小コスト。

リスクは、攻撃者がXSSで<script src="data:text/javascript,任意コード"></script>を仕込めるようになること。けれど既に'unsafe-inline'がある時点で同等の経路は開いているので、incremental riskは小さい。

セキュリティ評価ツール(Mozilla Observatory等)では減点対象。監査時に「なぜdata:が許可されているか」を聞かれます。

B: middleware nonce注入

CF Pages Functionsでfunctions/_middleware.tsを作り、HTMLレスポンスをHTMLRewriterで処理。リクエスト毎にランダムnonceを生成して<script>タグに付与し、CSPヘッダーにも同じnonceを埋める。

export const onRequest = async (context) => {
  const response = await context.next();
  if (!response.headers.get('content-type')?.startsWith('text/html')) {
    return response;
  }
  const nonce = crypto.randomUUID().replace(/-/g, '');
  // HTMLRewriterで<script>にnonce属性を付与
  // CSPヘッダーに 'nonce-...' 'strict-dynamic' 付与
  // ...
};

これならstrict-dynamicが正しく動きます。ClientRouter(nonced=trusted)が動的に作るdata: URIスクリプトも信頼の連鎖に入る。

デメリットは実装工数とHTMLキャッシュ無効化、middlewareの単一障害点リスク。6 URLのコーポレートサイトでROIが見合いにくい。

C: ClientRouter削除

<ClientRouter />をBaseLayoutから外せば、data: URIスクリプトは生成されなくなり、script-srcdata:を入れる必要がなくなります。

ページ遷移時のフェード演出は失います。実測では200〜400msの差。コーポレートサイトでこの差が事業に響くケースは稀。

このサイトで選んだ判断

自社サイトではAを選びました。理由は:

  • 6 URLの静的サイトでBの2〜3hはROIが見合わない
  • 既に'unsafe-inline'があるので、data:追加のincremental riskは小さい
  • 「妥協した経緯を記事化する」方が、サニタイズされたbest practice記事よりクライアントに刺さる

_headersファイルに判断理由をコメントで残しました。将来読み返した時に「なぜここに穴がある」を即理解できる状態。

# CSPにdata:をscript-srcへ含める判断について:
#   Astro View Transitions (ClientRouter)はランタイムでdata: URI scriptを
#   生成する設計。Astro security.csp integrationはClientRouter非対応と明記。
#   middlewareでnonce注入する選択肢があるが、現時点では実装コストROIが
#   見合わないため、interimとしてdata:を許可する。

いつBに進むか

クライアント案件で同等の構成 (View Transitions必須 + 完全strict CSP) を要求された時、または自社サイトのリニューアル時にBへ移行する想定。その時にこの記事を書いた経験と、自社サイトで触ったWorker構築の知見が、提案の解像度を上げます。


View Transitionsとstrict CSPは、Astro 6時点では両立できません。妥協なしで進めるならmiddlewareでnonce注入、コスト優先ならdata:を許可してその判断を文書化する。どちらを選ぶかはROIと監査要件次第です。自社サイトで触ってみるとAstroの限界点が体感でき、客への提案も「動くと思います」ではなく「やったことあります」で語れます。

Webセキュリティ設計のご相談はお問い合わせから、業務領域の詳細はServicesへ。