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: trueとorigins: '*'の組み合わせは、ブラウザが拒否するはずだ」
リョウは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
endWARNING
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
endINFO
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設定のテストを自動化している
- 許可していないオリジンからのリクエストをログに記録している