本文へスキップ
CodeAuditAgent
すべての記事

セキュリティヘッダー入門:CSP、HSTS、そしてその他

CSPのnonceとstrict-dynamic、HSTSとpreload、Referrer-PolicyやCOOPまで。Next.js設定例付きの実践ガイド。

· 7分で読めます · Lina Source LLC

セキュリティヘッダーは、サーバーがブラウザに与える指示です。実行してよいスクリプトはこれだけ、通信はHTTPSのみ、他のサイトにこのページをフレーム表示させない、といった具合です。コードのバグを直してくれるわけではありませんが、攻撃者がそのバグでできることを制限します。厳格なContent Security Policyの下にあるクロスサイトスクリプティングの穴は、同じ穴が何もない状態にあるよりずっと小さな問題です。

これらのヘッダーのほとんどは設定1行で済みます。例外はCSPで、こちらは計画が必要です。本稿では、各ヘッダーの役割、一般的なWebアプリケーションにとって妥当な値、そして本番を壊さずに展開する方法を扱います。

Content-Security-Policy

CSPは、スクリプト、スタイル、画像、フレーム、通信について、どのソースを許可するかをブラウザに伝えます。主な役割は、注入されたスクリプトの実行を止めることです。ドメインの許可リストが当然の方法に思えますが、これには長い迂回の歴史があります。ユーザーコンテンツや古いバージョンのライブラリを配信している許可済みCDNがあれば、攻撃者が制御するスクリプトの読み込みに使われてしまうからです。

nonceとstrict-dynamic

実際に通用するのはnonceベースのポリシーです。サーバーはレスポンスごとにランダムな値を生成し、それをCSPヘッダーに入れ、描画する各scriptタグにnonce属性として付与します。注入されたscriptタグはnonceを知らないためブロックされます。'strict-dynamic'を加えると、信頼されたスクリプトが読み込んだスクリプトがさらにスクリプトを読み込めるようになるため、すべてのドメインを列挙しなくてもバンドラーやタグマネージャーが動作し続けます。

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
  style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  upgrade-insecure-requests

実際のヘッダーは1行で送られます。ここでは読みやすさのために折り返しています。このポリシーのいくつかのディレクティブは、見た目以上の働きをします。object-src 'none' は旧来のプラグインコンテンツをブロックします。base-uri 'none' は、注入されたbaseタグが相対スクリプトURLの向き先を変えるのを防ぎます。form-action 'self' は、注入されたフォームが認証情報を外部へ送信するのを防ぎます。nonceは予測不可能で、レスポンスごとに新しくなければならないため、これを使うページは静的キャッシュから配信できません。

Report-Onlyで展開する

厳格なCSPをいきなり適用すると、必ず何かが壊れます。アナリティクスのスニペット、インラインのイベントハンドラー、サードパーティのウィジェットなどです。まずはContent-Security-Policy-Report-Onlyとして配信しましょう。ブラウザは何も適用せず、report-toやreport-uriディレクティブで指定されたエンドポイントにすべての違反を報告します。しばらくレポートを集め、正当なものは修正するか許可したうえで、ヘッダー名を適用用に切り替えます。適用後もレポートは継続してください。新しい違反は、デグレードか攻撃のいずれかだからです。ブラウザ拡張機能が独自のスクリプトをページに注入するため、レポートにはノイズが混じります。何を許可するか判断する前に、ソースで除外しましょう。

Strict-Transport-Security

HSTSは、ユーザーがhttp://と入力したり古いリンクをクリックしたりしても、一定期間はそのドメインでHTTPSを使うようブラウザに指示します。これにより、ネットワーク上の攻撃者が最初の平文HTTPリクエストを傍受してHTTPSへのリダイレクトを剥がせる隙が塞がります。ブラウザがこのヘッダーを受け入れるのはHTTPS経由で届いた場合だけで、HSTS対象ドメインでは証明書エラーをユーザーがクリックして通過することも拒否されます。それが狙いである一方、証明書を失効させたときのリスクでもあります。

まずは1日程度の短いmax-ageから始め、何も壊れないことを確認してから1〜2年に引き上げてください。includeSubDomainsはルールをすべてのサブドメインに広げるため、古いマーケティングサイトや同一ドメイン上の社内ツールも含め、すべてが有効なHTTPSを提供していることを先に確認しましょう。

preloadディレクティブは、ブラウザのプリロードリストへの申請と組み合わせることで、ドメインをブラウザに組み込み、初回訪問でもHTTPSが使われるようにします。これには最低1年のmax-ageとincludeSubDomainsが必要です。片道の扉だと考えてください。リストからの削除は可能ですが、ユーザーに行き渡るまで長い時間がかかり、その間HTTPSに対応できないサブドメインは到達不能になります。

1行で済むヘッダー

  • X-Content-Type-Options: nosniff。宣言したものと異なるコンテンツタイプをブラウザが推測するのを止め、テキストとして配信されたアップロードファイルがスクリプトとして実行されるのを防ぎます。
  • frame-ancestors(CSP内)とX-Frame-Options: DENY。自分のページを誰がフレームに埋め込めるかを制御し、クリックジャッキングへの防御になります。最近のブラウザは両方が設定されているとframe-ancestorsを使いX-Frame-Optionsを無視しますが、古いクライアント向けにX-Frame-Optionsも残しておきましょう。自サイト内でフレーム表示する場合は'self'やSAMEORIGINを使います。
  • Referrer-Policy: strict-origin-when-cross-origin。他サイトへはフルパスやクエリ文字列ではなくオリジンだけを送ります。URLに含まれるトークンやIDが第三者へ漏れるのを防ぎます。特に機微なページにはno-referrerを使ってください。
  • Permissions-Policy。camera=()、microphone=()、geolocation=()、payment=()のように、使っていないブラウザ機能を無効化し、注入されたコードや埋め込まれたコードが要求できないようにします。
  • Cross-Origin-Opener-Policy: same-origin。ページを独自のブラウジングコンテキストグループに置き、他サイトが開いたウィンドウから参照を保持できないようにします。OAuthや決済のポップアップに依存している場合はsame-origin-allow-popupsを使います。
  • Cross-Origin-Resource-Policy: same-originまたはsame-site。自分のレスポンスを、他のオリジンが画像やスクリプトなどのサブリソースとして読み込めないようブラウザに伝えます。兄弟サブドメインからアセットを配信している場合はsame-site、他所への埋め込みを想定したアセットにはcross-originを使います。

X-XSS-Protectionは外して構いません。これが制御していたフィルターは最近のブラウザから削除されており、CSPがその代わりです。スキャナーが指摘して譲らない場合は0に設定してください。同様に、攻撃者に情報を与えるだけのヘッダーは避けましょう。X-Powered-Byや詳細なServerバナーは、フレームワークとバージョンを無料で教えてしまいます。Next.jsでは設定でpoweredByHeader: falseとすれば前者を取り除けます。

Next.jsの設定例

静的なヘッダーはnext.config.tsに置き、headers()関数ですべてのルートに適用します。以下の値は、自身をフレームに埋め込まず、カメラや位置情報を使わず、自前のアセットを配信するアプリケーションにとって妥当な既定値です。そのまま写すのではなく、実際のアプリケーションの動作に合わせて1つずつ調整してください。

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(), payment=()",
  },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

export default nextConfig;

CSPにはリクエストごとに新しいnonceが必要なため、こちらはミドルウェアで設定します。Next.jsはリクエストのContent-Security-Policyヘッダーからnonceを読み取り、レンダリング時にフレームワーク自身のスクリプトへ適用します。自前のscriptタグ用には、サーバーコンポーネントでheaders().get("x-nonce")として読み取れます。

// middleware.ts(Next.js 16ではファイルがproxy.ts、関数がproxyになる)
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

nonceを使って描画するページは動的レンダリングでなければなりません。静的なページでは、すべての訪問者に同じnonceを使い回すことになるからです。展開中は、両方の場所でヘッダー名をContent-Security-Policy-Report-Onlyに変え、レポート用のエンドポイントを追加してください。開発環境では、Reactのデバッグ機能のためにscript-srcに'unsafe-eval'が必要になる場合があります。NODE_ENVがdevelopmentのときだけ追加しましょう。

確認の方法

  • トップページ、動的ページ、APIルート、静的アセットに対してcurl -sI https://your-domain.example/ を実行し、それぞれのヘッダーを確認します。HTMLページにしか設定されていないというのはよくある抜けです。
  • ブラウザの開発者ツールを開きます。CSPの違反は、該当ディレクティブとブロックされたリソースとともにコンソールに表示され、ネットワークタブでは実際に配信されたヘッダーを確認できます。
  • オリジンだけでなく、CDNやリバースプロキシの背後でも確認します。プロキシがヘッダーを削除・重複・上書きすることがあり、CSPヘッダーが2つ衝突している場合は両方が適用されるため、実効ポリシーはどちらよりも厳しくなります。
  • 手早く第二の意見を得るにはMozilla HTTP Observatoryのような公開チェッカーを、弱いCSPディレクティブを見つけるにはGoogleのCSP Evaluatorを使います。
  • 主要なルートにリクエストしてヘッダーの存在を検証するテストをCIに追加し、設定のリファクタリングで黙って失われないようにします。

ヘッダーは設定であり、だからこそずれていきます。独自にレスポンスを組み立てる新しいルートハンドラー、既定値を上書きするプロキシのルール、リリースの障害を解消するために'unsafe-inline'で緩められたCSP。コードと同じようにレビューしてください。CodeAuditAgentは、公開リポジトリや貼り付けられたスニペットの設定を他のコードとあわせて読み取り、欠落または弱められたヘッダーを深刻度、該当設定の引用、修正案とともに報告します。

妥当な進め方はこうです。1行で済むヘッダーは今日、短いmax-ageのHSTSは今週、nonceベースのCSPはレポートを集められるようになり次第Report-Onlyモードで。