mybook

CORS と Same-Origin Policy — ブラウザからのAPI呼び出しを制御する

「本番環境でフロントエンドからAPIが呼べない!」

新しい開発者のユイからSlackが来たのは、月曜日の朝だった。

Access to fetch at 'https://api.stockflow.example.com/api/v1/inventory' 
from origin 'https://app.stockflow.example.com' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.

「CORS設定を確認してくれ」とリョウが送り返すと、5分後に衝撃の返信が来た。

# 前任者のコード(config/initializers/cors.rb)
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'  # 全オリジンを許可!
    resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options]
    credentials true  # さらにcredentialsも許可!!
  end
end

「これは完全に間違っている。しかもcredentials: trueorigins: '*'の組み合わせは、ブラウザが拒否するはずだ」

リョウはCORSの正しい設定を一から実装し直すことにした。

Same-Origin Policyとは何か

ブラウザは「同一オリジンポリシー」を実施する。あるウェブページから、異なるオリジン(ドメイン、プロトコル、ポートの組み合わせ)へのリクエストを制限する仕組みだ。

Loading diagram...

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

CORSでは、実際のリクエストの前にブラウザが「許可されているか」を確認するOPTIONSリクエストを送る。

Loading diagram...

プリフライトが発生する条件(これ以外はシンプルリクエスト):

  • PUT、DELETE、PATCHメソッド
  • Content-Type: application/json(text/plain以外)
  • カスタムヘッダー(Authorization, X-API-Keyなど)

Railsでの正しいCORS設定

# Gemfile
gem 'rack-cors'
# config/initializers/cors.rb
allowed_origins = case Rails.env
when 'production'
  [
    'https://app.stockflow.example.com',
    'https://admin.stockflow.example.com',
    /\Ahttps:\/\/.+\.stockflow\.example\.com\z/  # サブドメインを許可(Regexp使用可能)
  ]
when 'staging'
  [
    'https://staging.stockflow.example.com',
    /\Ahttps:\/\/preview-\d+\.stockflow-staging\.com\z/  # プレビュー環境
  ]
when 'development'
  [
    'http://localhost:3001',
    'http://localhost:5173',  # Viteのデフォルト
    'http://127.0.0.1:3001'
  ]
end
 
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins(*allowed_origins)
 
    resource '/api/*',
      headers: %w[
        Authorization
        Content-Type
        X-Requested-With
        X-API-Key
        X-Request-ID
      ],
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: %w[
        X-RateLimit-Limit
        X-RateLimit-Remaining
        X-RateLimit-Reset
        X-Request-ID
      ],
      credentials: true,
      max_age: 7200  # プリフライトキャッシュ: 2時間
 
    # 公開APIは認証情報なしで広範なオリジンを許可
    resource '/api/v1/public/*',
      headers: :any,
      methods: [:get, :options],
      credentials: false,
      max_age: 86400  # 24時間
  end
end

WARNING

credentials: true(Cookieや認証ヘッダーを含むリクエストを許可)とorigins: '*'(全オリジン許可)は同時に使用できません。ブラウザが拒否します。credentialsを使う場合は、必ず特定のオリジンを明示する必要があります。

オリジン検証のカスタムロジック

より複雑な条件でオリジンを検証する場合は、カスタムミドルウェアを使う。

# app/middleware/cors_validator.rb
class CorsValidator
  ALLOWED_ORIGINS = Rails.application.config.allowed_cors_origins
 
  def initialize(app)
    @app = app
  end
 
  def call(env)
    request = Rack::Request.new(env)
    origin = env['HTTP_ORIGIN']
 
    if origin
      if allowed_origin?(origin)
        env['HTTP_X_CORS_VALID'] = 'true'
      else
        # 許可されていないオリジンからのリクエストをログに記録
        Rails.logger.warn({
          event: 'cors_rejected',
          origin: origin,
          path: request.path,
          ip: request.ip
        }.to_json)
      end
    end
 
    @app.call(env)
  end
 
  private
 
  def allowed_origin?(origin)
    ALLOWED_ORIGINS.any? do |allowed|
      case allowed
      when String then origin == allowed
      when Regexp then origin.match?(allowed)
      end
    end
  end
end

フロントエンドでのCORSエラーのデバッグ

// フロントエンドでのAPI呼び出し
async function fetchInventory() {
  try {
    const response = await fetch('https://api.stockflow.example.com/api/v1/inventory', {
      method: 'GET',
      credentials: 'include',  // Cookieを送る場合
      headers: {
        'Authorization': `Bearer ${getAccessToken()}`,
        'Content-Type': 'application/json',
        'X-Request-ID': crypto.randomUUID()  // トレース用
      }
    });
 
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
 
    return await response.json();
  } catch (error) {
    if (error.message.includes('CORS')) {
      console.error('CORS error: Check the API server CORS configuration');
    }
    throw error;
  }
}

API Gatewayでの集中CORS管理

マイクロサービス構成では、API GatewayでCORSを集中管理する。

# AWS API Gateway(Terraform)
resource "aws_api_gateway_method" "options" {
  rest_api_id   = aws_api_gateway_rest_api.main.id
  resource_id   = aws_api_gateway_resource.proxy.id
  http_method   = "OPTIONS"
  authorization = "NONE"
}
 
resource "aws_api_gateway_integration" "options" {
  rest_api_id = aws_api_gateway_rest_api.main.id
  resource_id = aws_api_gateway_resource.proxy.id
  http_method = aws_api_gateway_method.options.http_method
  type        = "MOCK"
 
  request_templates = {
    "application/json" = "{\"statusCode\": 200}"
  }
}
 
resource "aws_api_gateway_method_response" "options_200" {
  rest_api_id = aws_api_gateway_rest_api.main.id
  resource_id = aws_api_gateway_resource.proxy.id
  http_method = aws_api_gateway_method.options.http_method
  status_code = "200"
 
  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true
    "method.response.header.Access-Control-Allow-Origin"  = true
    "method.response.header.Access-Control-Max-Age"       = true
  }
}
 
resource "aws_api_gateway_integration_response" "options" {
  rest_api_id = aws_api_gateway_rest_api.main.id
  resource_id = aws_api_gateway_resource.proxy.id
  http_method = aws_api_gateway_method.options.http_method
  status_code = "200"
 
  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = "'Authorization,Content-Type,X-API-Key'"
    "method.response.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
    "method.response.header.Access-Control-Allow-Origin"  = "'https://app.stockflow.example.com'"
    "method.response.header.Access-Control-Max-Age"       = "'7200'"
  }
}

テストでCORSを検証する

# spec/requests/cors_spec.rb
RSpec.describe "CORS", type: :request do
  describe "Allowed origins" do
    let(:allowed_origins) do
      [
        "https://app.stockflow.example.com",
        "https://admin.stockflow.example.com"
      ]
    end
 
    it "allows requests from permitted origins" do
      allowed_origins.each do |origin|
        get "/api/v1/inventory",
          headers: { "Origin" => origin, "Authorization" => "Bearer #{valid_token}" }
 
        expect(response.headers['Access-Control-Allow-Origin']).to eq(origin)
        expect(response.headers['Vary']).to include('Origin')
      end
    end
 
    it "rejects requests from unpermitted origins" do
      get "/api/v1/inventory",
        headers: {
          "Origin" => "https://evil.example.com",
          "Authorization" => "Bearer #{valid_token}"
        }
 
      expect(response.headers['Access-Control-Allow-Origin']).to be_nil
    end
 
    it "handles preflight requests correctly" do
      options "/api/v1/inventory",
        headers: {
          "Origin" => "https://app.stockflow.example.com",
          "Access-Control-Request-Method" => "DELETE",
          "Access-Control-Request-Headers" => "Authorization"
        }
 
      expect(response.status).to eq(200)
      expect(response.headers['Access-Control-Allow-Methods']).to include('DELETE')
      expect(response.headers['Access-Control-Max-Age']).to be_present
    end
 
    it "does not allow credentials with wildcard origin" do
      # 本来このケースはブラウザが拒否するが、サーバー側でも適切に設定されているか確認
      get "/api/v1/inventory",
        headers: { "Origin" => "https://app.stockflow.example.com" }
 
      # credentialsが有効な場合、*ではなく具体的なオリジンが返るはず
      expect(response.headers['Access-Control-Allow-Origin']).not_to eq('*')
    end
  end
 
  describe "Vary header" do
    it "includes Origin in Vary header" do
      get "/api/v1/inventory",
        headers: {
          "Origin" => "https://app.stockflow.example.com",
          "Authorization" => "Bearer #{valid_token}"
        }
 
      # キャッシュがオリジン別に保存されるよう Vary: Origin が必要
      expect(response.headers['Vary']).to include('Origin')
    end
  end
end

INFO

Vary: Originヘッダーは、CORSレスポンスがオリジンによって異なることをキャッシュ(CDNやブラウザ)に伝えます。このヘッダーがないと、あるオリジンへのレスポンスが別のオリジンへのリクエストにキャッシュされ、CORSエラーが発生することがあります。

CORS設定のチェックツール

# curlでCORSをテスト
# プリフライトリクエスト
curl -X OPTIONS https://api.stockflow.example.com/api/v1/inventory \
  -H "Origin: https://app.stockflow.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization" \
  -v 2>&1 | grep -E "Access-Control|< HTTP"
 
# 実際のリクエスト
curl -X GET https://api.stockflow.example.com/api/v1/inventory \
  -H "Origin: https://app.stockflow.example.com" \
  -H "Authorization: Bearer ${TOKEN}" \
  -v 2>&1 | grep -E "Access-Control|< HTTP"
 
# 悪意のあるオリジンからのテスト(許可されないこと確認)
curl -X GET https://api.stockflow.example.com/api/v1/inventory \
  -H "Origin: https://evil.example.com" \
  -H "Authorization: Bearer ${TOKEN}" \
  -v 2>&1 | grep "Access-Control-Allow-Origin"
# 出力がなければ正常(悪意のあるオリジンを拒否)

チェックリスト

  • 許可するオリジンを明示的にホワイトリスト化している(*は本番環境で禁止)
  • credentials: trueの場合、origins: '*'は設定していない
  • 環境ごとに許可オリジンを分けて設定している
  • プリフライトリクエスト(OPTIONS)を適切に処理している
  • Access-Control-Max-Ageでプリフライトのキャッシュ時間を設定している
  • Vary: Originヘッダーをレスポンスに含めている
  • 公開APIと認証APIでCORS設定を分けている
  • CORS設定のテストを自動化している
  • 許可していないオリジンからのリクエストをログに記録している