CORS Security

CORS(Cross-Origin Resource Sharing、クロスオリジンリソース共有)は、最新の ブラウザによって実装されているセキュリティメカニズムであり、Web アプリケーションが元のページを 配信したドメインとは異なるドメインへ HTTP リクエストを行う方法を制御し、 Same-Origin Policy (SOP) を制御された形で緩和します。SOP は、 あるオリジンのスクリプトが別のオリジンのリソースにアクセスすることを制限する 基本的なセキュリティポリシーです。CORS は、フロントエンドが異なるドメインで ホストされた API を利用する必要が頻繁にある最新の Web アプリケーションアーキテクチャにとって 不可欠ですが、その不適切な設定は、現代の Web アプリケーションにおいて 最も一般的で危険な脆弱性の 1 つとなっています。 CORS の設定ミスは、機密データを許可されていないドメインに公開し、 アンチ CSRF トークンが存在していてもクロスサイトリクエストフォージェリ (CSRF) 攻撃を可能にし、 認証情報の窃取を助長し、極端なケースでは、認証済みユーザーになりすまして攻撃者が 特権的な操作を実行できるようにする可能性があります。この問題は、多くの 開発者が開発中に CORS エラーに遭遇した際、セキュリティ上の影響を十分に理解しないまま、 過度に寛容な解決策(ワイルドカード "*" の使用や、リクエストのオリジンを自動的に反映させるなど)を 選択するという事実によって悪化します。本記事では、CORS の基礎、 一般的な設定の脆弱性を詳しく掘り下げ、さまざまなプラットフォームやフレームワークにわたる 安全な実装のための堅牢なプラクティスを確立し、機能性と適切な防御的姿勢のバランスを取ります。

Same-Origin Policy (SOP)

ブラウザは SOP を実装しています。スクリプトは同一オリジン (プロトコル + ドメイン + ポート)のリソースにのみアクセスできます。CORS は SOP を制御された形で緩和します。

      # 同一オリジン
      https://example.com/api ← https://example.com/app [OK]
      # 異なるオリジン(SOP によりブロック)
      https://example.com ← http://example.com (プロトコル)
      https://example.com ← https://api.example.com (サブドメイン)
      https://example.com ← https://example.com:8080 (ポート)
      

CORS Headers

Access-Control-Allow-Origin

      # 特定のオリジンを許可(推奨)
      Access-Control-Allow-Origin: https://trusted.com
      # 任意のオリジンを許可(危険!)
      Access-Control-Allow-Origin: *
      # ホワイトリストに基づく動的設定(正しい)
      const allowedOrigins = ['https://app1.com', 'https://app2.com'];
      const origin = request.headers.origin;
      if (allowedOrigins.includes(origin)) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      }
      

その他の重要な Headers

      # 認証情報を許可(cookies、auth headers)
      Access-Control-Allow-Credentials: true
      # 許可する HTTP メソッド
      Access-Control-Allow-Methods: GET, POST, PUT, DELETE
      # リクエストで許可する headers
      Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
      # クライアントサイド JavaScript に公開する headers
      Access-Control-Expose-Headers: X-Custom-Header, X-Request-Id
      # プリフライトのキャッシュ時間(秒)
      Access-Control-Max-Age: 86400
      

Preflight Requests

ブラウザは「単純でない」リクエストの前に OPTIONS リクエストを送信して権限を確認します。

      # クライアントがプリフライトを送信
      OPTIONS /api/resource HTTP/1.1
      Origin: https://app.com
      Access-Control-Request-Method: DELETE
      Access-Control-Request-Headers: Authorization
      # サーバーが権限を返答
      HTTP/1.1 204 No Content
      Access-Control-Allow-Origin: https://app.com
      Access-Control-Allow-Methods: GET, POST, DELETE
      Access-Control-Allow-Headers: Authorization
      Access-Control-Max-Age: 86400
      

一般的な CORS の脆弱性

1. 認証情報を伴うワイルドカード

      # [エラー] 脆弱 - 機能せず危険
      Access-Control-Allow-Origin: *
      Access-Control-Allow-Credentials: true
      # ブラウザはこの組み合わせをブロックします
      # [OK] 正しい - 認証情報を伴う特定のオリジン
      Access-Control-Allow-Origin: https://trusted.com
      Access-Control-Allow-Credentials: true
      

2. Reflection Attack

      # [エラー] 脆弱 - 任意の origin を反映
      const origin = request.headers.origin;
      res.setHeader('Access-Control-Allow-Origin', origin);
      res.setHeader('Access-Control-Allow-Credentials', 'true');
      # [OK] 正しい - ホワイトリスト検証
      const allowedOrigins = ['https://app.com', 'https://admin.com'];
      const origin = request.headers.origin;
      if (allowedOrigins.includes(origin)) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      res.setHeader('Access-Control-Allow-Credentials', 'true');
      }
      

3. Subdomain Wildcard

      # [エラー] 脆弱 - 不適切に実装された regex
      const origin = request.headers.origin;
      if (/https:\/\/.*\.example\.com/.test(origin)) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      }
      // https://evil.example.com.attacker.com を受け入れてしまう
      # [OK] 正しい - 厳格な検証
      const origin = request.headers.origin;
      if (/^https:\/\/[a-z0-9-]+\.example\.com$/.test(origin)) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      }
      

技術別の安全な設定

Node.js/Express (CORS middleware)

      const cors = require('cors');
      // 安全な設定
      const corsOptions = {
      origin: function (origin, callback) {
      const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com'
      ];
      if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
      } else {
      callback(new Error('Not allowed by CORS'));
      }
      },
      credentials: true,
      methods: ['GET', 'POST', 'PUT', 'DELETE'],
      allowedHeaders: ['Content-Type', 'Authorization'],
      maxAge: 86400
      };
      app.use(cors(corsOptions));
      

Nginx

      # 条件付き設定
      map $http_origin $cors_origin {
      default "";
      "~^https://app\\.example\\.com$" $http_origin;
      "~^https://admin\\.example\\.com$" $http_origin;
      }
      server {
      location /api {
      if ($cors_origin != "") {
      add_header Access-Control-Allow-Origin $cors_origin always;
      add_header Access-Control-Allow-Credentials true always;
      add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
      add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
      }
      if ($request_method = OPTIONS) {
      return 204;
      }
      }
      }
      

Apache

      # .htaccess
      SetEnvIf Origin "^https://(app|admin)\\.example\\.com$" CORS_ORIGIN=$0
      Header always set Access-Control-Allow-Origin "%e" env=CORS_ORIGIN
      Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
      Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE" env=CORS_ORIGIN
      Header always set Access-Control-Allow-Headers "Authorization, Content-Type" env=CORS_ORIGIN
      # OPTIONS プリフライトに応答
      RewriteEngine On
      RewriteCond % OPTIONS
      RewriteRule ^(.*)$ $1 [R=204,L]
      

Testing CORS

      # curl でのテスト
      curl -H "Origin: https://evil.com" \\
      -H "Access-Control-Request-Method: DELETE" \\
      -H "Access-Control-Request-Headers: Authorization" \\
      -X OPTIONS \\
      https://api.example.com/resource
      # JavaScript テスト
      fetch('https://api.example.com/data', {
      method: 'GET',
      credentials: 'include',
      headers: {
      'Content-Type': 'application/json'
      }
      }).then(response => console.log(response));
      

Best Practices

  • 機密データを扱う API では決してワイルドカード (*) を使用しない
  • 明示的なホワイトリストで許可するオリジンを指定する
  • 厳格な検証を安全な regex でオリジンに対して行う
  • credentials を最小限に:本当に必要な場合のみ有効にする
  • Least privilege(最小権限):必要なメソッドと headers のみを許可する
  • Cache preflight(プリフライトのキャッシュ):Max-Age を使用してオーバーヘッドを削減する
  • Monitoring(監視):疑わしい CORS の拒否をログに記録する

安全な CORS チェックリスト

  • [OK] オリジンが明示的なホワイトリストに対して検証されている
  • [OK] 検証用 regex がバイパスを許可しない
  • [OK] Credentials は必要な場合にのみ有効化されている
  • [OK] メソッドと headers が最小限に制限されている
  • [OK] プリフライトが正しく設定されている
  • [OK] 悪意のあるオリジンに対してテスト済み
  • [OK] 拒否されたリクエストのログが監視されている