T-CREATOR

SvelteKit 本番運用チェックリスト:CSP/SRI/Cache-Control/Headers 総点検

SvelteKit 本番運用チェックリスト:CSP/SRI/Cache-Control/Headers 総点検

SvelteKit でアプリケーションを開発し、いざ本番環境へリリース。しかし、セキュリティやパフォーマンスの設定を見落としていませんか?

本番運用では、Content Security Policy(CSP)、Subresource Integrity(SRI)、Cache-Control、HTTP ヘッダーなど、多岐にわたる設定が求められます。これらを適切に構成することで、XSS 攻撃の防止、改ざん検知、キャッシュ効率化、セキュリティヘッダーによる脅威軽減が実現できるのです。

本記事では、SvelteKit アプリケーションを本番環境で安全かつ高速に運用するために必要な設定項目を総点検します。初心者の方でも実践できるよう、各項目の背景・課題・解決策・具体例を段階的に解説していきますね。

背景

Web アプリケーションのセキュリティとパフォーマンスの重要性

Web アプリケーションは、リリース後も常に外部からの攻撃やパフォーマンス低下のリスクにさらされています。特に以下のような脅威が存在します。

#脅威影響
1XSS(クロスサイトスクリプティング)悪意のあるスクリプトが実行され、ユーザー情報が窃取される
2CDN や外部リソースの改ざん第三者による不正なコードの挿入
3キャッシュ設定の不備サーバー負荷増大、ページ表示速度の低下
4セキュリティヘッダーの欠如クリックジャッキング、MIME タイプスニッフィングなどの攻撃

これらのリスクに対処するため、本番環境では複数のセキュリティ層を設ける必要があります。

以下の図は、SvelteKit アプリケーションにおけるセキュリティとパフォーマンスの全体像を示しています。

mermaidflowchart TB
  user["ユーザー"]
  browser["ブラウザ"]
  server["SvelteKit<br/>サーバー"]
  cdn["CDN/<br/>外部リソース"]

  user -->|アクセス| browser
  browser -->|リクエスト| server
  server -->|セキュリティヘッダー<br/>CSP, Cache-Control| browser
  browser -->|外部リソース<br/>取得| cdn
  cdn -->|JS/CSS<br/>SRI検証| browser
  browser -->|レンダリング<br/>実行| user

この図のように、サーバーからブラウザへ送信される HTTP ヘッダー、外部リソースの整合性検証、キャッシュ制御が連携して、セキュリティとパフォーマンスを実現します。

図で理解できる要点:

  • サーバーは HTTP ヘッダーを通じてブラウザに指示を出す
  • 外部リソースは SRI によって整合性が検証される
  • 複数のセキュリティ層が協調して動作する

SvelteKit の本番環境特有の考慮事項

SvelteKit は、SSR(Server-Side Rendering)、CSR(Client-Side Rendering)、SSG(Static Site Generation)を柔軟に組み合わせられるフレームワークです。この柔軟性ゆえに、本番環境では以下の点に注意が必要です。

  • adapter による環境差異: adapter-node、adapter-vercel など、デプロイ先によって設定方法が異なる
  • 動的レンダリングと静的生成の混在: ページごとに異なるキャッシュ戦略が必要
  • CSP と nonce の管理: SSR 時に動的に nonce を生成し、インラインスクリプトを許可する仕組みが必要

これらの特性を理解したうえで、適切な設定を行うことが本番運用の成功につながります。

課題

セキュリティ設定の複雑さ

SvelteKit アプリケーションを本番環境で運用する際、以下のような課題に直面します。

1. CSP(Content Security Policy)の設定が難しい

CSP は、許可されたリソースのみを読み込むようブラウザに指示するセキュリティヘッダーです。しかし、以下の理由で設定が複雑になりがちです。

  • インラインスクリプトやスタイルをどう許可するか
  • 外部 CDN や Google Fonts などのリソースをどう扱うか
  • nonce や hash の管理方法

誤った設定をすると、アプリケーションが正常に動作しなくなる可能性があります。

2. SRI(Subresource Integrity)の実装が手間

SRI は、外部リソースが改ざんされていないかをハッシュ値で検証する仕組みです。しかし、以下の手間がかかります。

  • CDN から読み込む各リソースのハッシュ値を生成
  • バージョンアップ時にハッシュ値を更新
  • ビルド時に自動生成する仕組みの構築

3. Cache-Control の最適化が不明確

キャッシュ戦略は、パフォーマンスに直結します。しかし、以下の疑問が生じます。

  • 静的アセット(JS、CSS、画像)にどのくらいの有効期限を設定すべきか
  • HTML ページはキャッシュすべきか、毎回サーバーに問い合わせるべきか
  • API レスポンスのキャッシュ戦略は?

4. セキュリティヘッダーの漏れ

以下のようなセキュリティヘッダーを設定し忘れると、脆弱性が残ります。

#ヘッダー目的
1X-Frame-Optionsクリックジャッキング防止
2X-Content-Type-OptionsMIME タイプスニッフィング防止
3Referrer-Policyリファラー情報の制御
4Permissions-Policyブラウザ機能の制限

以下の図は、これらの課題がどのように関連しているかを示しています。

mermaidflowchart LR
  csp["CSP設定<br/>複雑"]
  sri["SRI実装<br/>手間"]
  cache["Cache-Control<br/>不明確"]
  headers["セキュリティヘッダー<br/>漏れ"]

  risk["セキュリティリスク<br/>パフォーマンス低下"]

  csp --> risk
  sri --> risk
  cache --> risk
  headers --> risk

これらの課題を放置すると、セキュリティリスクやパフォーマンス低下につながります。次のセクションでは、これらを解決する具体的な方法を見ていきましょう。

解決策

1. CSP(Content Security Policy)の設定

CSP を適切に設定することで、XSS 攻撃を効果的に防げます。SvelteKit では、hooks.server.ts で HTTP ヘッダーを設定するのが基本です。

CSP の基本方針

以下の方針で CSP を設定します。

  • default-src: デフォルトで自分のドメインのみ許可
  • script-src: nonce を使ってインラインスクリプトを許可
  • style-src: nonce またはハッシュでインラインスタイルを許可
  • img-src / font-src: 必要な外部ドメインを明示的に許可

nonce の生成と適用

nonce は、リクエストごとに一意のランダム値を生成し、許可されたスクリプトやスタイルにのみ付与します。

以下の図は、CSP と nonce の動作フローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Server as SvelteKit<br/>サーバー
  participant Browser as ブラウザ

  User->>Server: ページリクエスト
  Server->>Server: nonce生成
  Server->>Browser: HTML + CSP header<br/>(nonce含む)
  Browser->>Browser: CSP検証<br/>nonce一致確認
  Browser->>User: ページ表示

このように、サーバーで生成された nonce がブラウザで検証されることで、許可されたスクリプトのみが実行されます。

2. SRI(Subresource Integrity)の実装

SRI は、外部リソースの整合性を検証するための仕組みです。CDN から読み込む JavaScript や CSS に対して、ハッシュ値を指定します。

SRI の実装方針

  • ビルド時にハッシュ値を自動生成: Vite プラグインや専用ツールを活用
  • CDN リソースには手動でハッシュ値を設定: 公式サイトから取得
  • クロスオリジンリソースには crossorigin 属性を追加

3. Cache-Control の最適化

キャッシュ戦略は、リソースの種類によって使い分けます。

#リソースCache-Control理由
1静的アセット(JS/CSS)public, max-age=31536000, immutableハッシュ付きファイル名なので長期キャッシュ可能
2HTML ページno-cache または max-age=0常に最新版を取得
3API レスポンスprivate, max-age=60ユーザーごとに異なるデータ
4画像public, max-age=864001 日程度のキャッシュ

キャッシュ戦略の図解

以下の図は、リソースごとのキャッシュフローを示しています。

mermaidflowchart TD
  request["リクエスト"]
  check["キャッシュ確認"]

  cached["キャッシュ有効"]
  expired["キャッシュ期限切れ"]

  serve["キャッシュから<br/>返却"]
  fetch["サーバーへ<br/>リクエスト"]
  update["キャッシュ<br/>更新"]

  request --> check
  check --> cached
  check --> expired
  cached --> serve
  expired --> fetch
  fetch --> update
  update --> serve

この仕組みにより、有効なキャッシュがあれば即座に返却し、期限切れの場合のみサーバーへリクエストすることで、パフォーマンスが向上します。

4. セキュリティヘッダーの設定

以下のセキュリティヘッダーを設定することで、多層的な防御を実現します。

#ヘッダー推奨値効果
1X-Frame-OptionsDENY または SAMEORIGINiframe への埋め込みを制限
2X-Content-Type-OptionsnosniffMIME タイプの推測を防止
3Referrer-Policystrict-origin-when-cross-originリファラー情報の制御
4Permissions-Policygeolocation=(), microphone=()不要な機能を無効化
5Strict-Transport-Securitymax-age=31536000; includeSubDomainsHTTPS 強制

これらのヘッダーを hooks.server.ts で一括設定することで、セキュリティを強化できます。

具体例

完全な hooks.server.ts の実装

ここでは、CSP、SRI、Cache-Control、セキュリティヘッダーをすべて設定した hooks.server.ts の実装例を段階的に解説します。

ステップ 1: nonce 生成関数の実装

リクエストごとに一意の nonce を生成します。

typescript// src/hooks.server.ts

import { randomBytes } from 'crypto';

/**
 * CSP nonce を生成する関数
 * ランダムな16バイトをBase64エンコードして返します
 */
function generateNonce(): string {
  return randomBytes(16).toString('base64');
}

randomBytes を使って暗号学的に安全なランダム値を生成し、Base64 エンコードすることで nonce として利用できる形式にします。

ステップ 2: CSP ヘッダーの構築

nonce を含む CSP ヘッダーを生成します。

typescript/**
 * CSP ヘッダーを構築する関数
 * nonce を使ってインラインスクリプトとスタイルを許可します
 */
function buildCSP(nonce: string): string {
  const directives = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
    `style-src 'self' 'nonce-${nonce}' https://fonts.googleapis.com`,
    `font-src 'self' https://fonts.gstatic.com`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
  ];

  return directives.join('; ');
}

各ディレクティブで、許可するリソースの取得元を明示的に指定しています。nonce-${nonce} により、サーバーが生成した nonce を持つスクリプトとスタイルのみが実行されます。

ステップ 3: セキュリティヘッダーの設定

複数のセキュリティヘッダーをまとめて設定します。

typescript/**
 * セキュリティヘッダーを設定する関数
 * CSP以外の主要なセキュリティヘッダーを返します
 */
function getSecurityHeaders(): Record<string, string> {
  return {
    'X-Frame-Options': 'DENY',
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'Permissions-Policy':
      'geolocation=(), microphone=(), camera=()',
    'Strict-Transport-Security':
      'max-age=31536000; includeSubDomains; preload',
  };
}

これらのヘッダーにより、クリックジャッキング、MIME スニッフィング、不要なブラウザ機能の利用などを防ぎます。

ステップ 4: Cache-Control の設定

リソースの種類に応じて Cache-Control を設定します。

typescript/**
 * リソースタイプに応じた Cache-Control を返す関数
 * パスから適切なキャッシュ戦略を判定します
 */
function getCacheControl(pathname: string): string {
  // 静的アセット(ハッシュ付きファイル名)
  if (pathname.match(/\/_app\/immutable\//)) {
    return 'public, max-age=31536000, immutable';
  }

  // 画像ファイル
  if (pathname.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)) {
    return 'public, max-age=86400';
  }

  // HTMLページ
  if (
    pathname.endsWith('.html') ||
    !pathname.includes('.')
  ) {
    return 'no-cache';
  }

  // その他の静的ファイル
  return 'public, max-age=3600';
}

SvelteKit は、ビルド時に _app​/​immutable​/​ ディレクトリ配下に内容ハッシュを含むファイル名で静的アセットを出力します。これらは内容が変わらない限りファイル名も変わらないため、長期キャッシュが可能です。

ステップ 5: handle フックの実装

すべてのリクエストに対してヘッダーを設定します。

typescriptimport type { Handle } from '@sveltejs/kit';

/**
 * SvelteKit の handle フック
 * すべてのリクエストに対してセキュリティヘッダーを設定します
 */
export const handle: Handle = async ({ event, resolve }) => {
  // nonce を生成
  const nonce = generateNonce();

  // nonce をローカル変数に保存(テンプレートで使用可能)
  event.locals.nonce = nonce;

  // レスポンスを取得
  const response = await resolve(event, {
    transformPageChunk: ({ html }) => {
      // HTML内のプレースホルダーをnonceに置換
      return html.replace('%sveltekit.nonce%', nonce);
    }
  });

transformPageChunk を使うことで、HTML 内の %sveltekit.nonce% プレースホルダーを実際の nonce 値に置換できます。

ステップ 6: レスポンスヘッダーの設定

生成された nonce と各種ヘッダーをレスポンスに追加します。

typescript  // CSPヘッダーを設定
  response.headers.set('Content-Security-Policy', buildCSP(nonce));

  // その他のセキュリティヘッダーを設定
  const securityHeaders = getSecurityHeaders();
  Object.entries(securityHeaders).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

  // Cache-Controlを設定
  const cacheControl = getCacheControl(event.url.pathname);
  response.headers.set('Cache-Control', cacheControl);

  return response;
};

すべてのセキュリティヘッダーとキャッシュ制御ヘッダーをレスポンスに追加して返します。

ステップ 7: 型定義の追加

TypeScript で event.locals.nonce を使えるよう型定義を追加します。

typescript// src/app.d.ts

declare global {
  namespace App {
    interface Locals {
      nonce: string;
    }
  }
}

export {};

これにより、event.locals.nonce が型安全に利用できます。

HTML テンプレートでの nonce 使用

src​/​app.html で nonce を使ってインラインスクリプトを許可します。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link
      rel="icon"
      href="%sveltekit.assets%/favicon.png"
    />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1"
    />

    <!-- Google Fonts の読み込み -->
    <link
      rel="preconnect"
      href="https://fonts.googleapis.com"
    />
    <link
      rel="preconnect"
      href="https://fonts.gstatic.com"
      crossorigin
    />

    %sveltekit.head%
  </head>
</html>

ヘッダー部分で、外部フォントの preconnect を設定しています。CSP で許可したドメインと一致させることが重要です。

html<body data-sveltekit-preload-data="hover">
  <div style="display: contents">%sveltekit.body%</div>

  <!-- インラインスクリプトにnonceを付与 -->
  <script nonce="%sveltekit.nonce%">
    // 初期化処理など
    console.log('App initialized');
  </script>
</body>
</html>

%sveltekit.nonce%transformPageChunk で実際の nonce 値に置換され、CSP で許可されたスクリプトとして実行されます。

SRI の実装例

外部 CDN から読み込むリソースに SRI を適用します。

html<!-- jQuery を CDN から SRI 付きで読み込む -->
<script
  src="https://code.jquery.com/jquery-3.7.1.min.js"
  integrity="sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs"
  crossorigin="anonymous"
></script>

integrity 属性にハッシュ値を指定し、crossorigin 属性を追加することで、SRI が有効になります。

html<!-- Bootstrap CSS を CDN から SRI 付きで読み込む -->
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
  integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
  crossorigin="anonymous"
/>

CSS ファイルにも同様に SRI を適用できます。ハッシュ値は、CDN の公式サイトや SRI Hash Generator などのツールで取得できますよ。

ビルド時の SRI 自動生成(Vite プラグイン)

Vite プラグインを使うことで、ビルド時に自動的に SRI ハッシュを生成できます。

typescript// vite.config.ts

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

/**
 * Vite の設定
 * SRI プラグインを追加してビルド時にハッシュを生成します
 */
export default defineConfig({
  plugins: [
    sveltekit(),
    // SRI プラグイン(例: vite-plugin-sri)
  ],
});

プラグインの具体的な実装は、vite-plugin-sri などのパッケージを利用することで簡単に導入できます。

API ルートでのキャッシュ制御

API エンドポイントでも、レスポンスごとに適切な Cache-Control を設定します。

typescript// src/routes/api/users/+server.ts

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';

/**
 * ユーザー一覧を返すAPIエンドポイント
 * プライベートキャッシュを60秒間有効にします
 */
export const GET: RequestHandler = async () => {
  const users = [
    { id: 1, name: '田中太郎' },
    { id: 2, name: '佐藤花子' },
  ];

  return json(users, {
    headers: {
      'Cache-Control': 'private, max-age=60',
    },
  });
};

API レスポンスには private を指定し、ユーザーごとに異なるデータであることを明示します。共有キャッシュ(CDN など)には保存されず、ブラウザのみがキャッシュします。

環境変数による CSP の切り替え

開発環境と本番環境で CSP の厳格さを変えることも可能です。

typescript// src/hooks.server.ts

import { dev } from '$app/environment';

/**
 * 環境に応じた CSP を構築
 * 開発環境では制限を緩和します
 */
function buildCSP(nonce: string): string {
  if (dev) {
    // 開発環境: 制限を緩和
    return `default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval'`;
  }

  // 本番環境: 厳格な設定
  const directives = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}'`,
    // ...
  ];

  return directives.join('; ');
}

開発中は HMR(Hot Module Replacement)などで unsafe-inlineunsafe-eval が必要になる場合があります。本番環境では厳格な CSP を適用しましょう。

デプロイ先別の設定例

Vercel でのヘッダー設定

Vercel では、vercel.json でもヘッダーを設定できます。

json{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        }
      ]
    }
  ]
}

ただし、動的な nonce を含む CSP は hooks.server.ts で設定する必要があります。

Nginx でのヘッダー設定

Nginx を使う場合は、設定ファイルでヘッダーを追加できます。

nginx# /etc/nginx/sites-available/myapp

server {
  listen 443 ssl http2;
  server_name example.com;

  # セキュリティヘッダー
  add_header X-Frame-Options "DENY" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

  # 静的アセットのキャッシュ
  location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # SvelteKit アプリケーションへプロキシ
  location / {
    proxy_pass http://localhost:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

Nginx 層でヘッダーを設定することで、SvelteKit アプリケーションの負荷を軽減できます。

まとめ

本記事では、SvelteKit アプリケーションを本番環境で安全かつ高速に運用するための設定を総点検しました。

CSP(Content Security Policy) は、nonce を使ってインラインスクリプトを安全に許可し、XSS 攻撃を防ぎます。hooks.server.ts でリクエストごとに一意の nonce を生成し、HTML テンプレートに埋め込むことで実現できるのです。

SRI(Subresource Integrity) は、外部 CDN から読み込むリソースが改ざんされていないことをハッシュ値で検証します。integrity 属性と crossorigin 属性を追加するだけで、簡単に導入できますね。

Cache-Control は、リソースの種類に応じて適切なキャッシュ戦略を設定します。静的アセットは長期キャッシュ、HTML ページは常に最新版を取得、API レスポンスはプライベートキャッシュといった使い分けが重要です。

セキュリティヘッダー は、多層的な防御を実現します。X-Frame-OptionsX-Content-Type-OptionsReferrer-PolicyPermissions-PolicyStrict-Transport-Security を設定することで、さまざまな攻撃から守れるでしょう。

これらの設定を適切に行うことで、SvelteKit アプリケーションのセキュリティとパフォーマンスが大幅に向上します。本記事のチェックリストを参考に、ぜひ本番環境の設定を見直してみてくださいね。

関連リンク