Aikido

CORSセキュリティ: 基本設定を超えて

執筆者
Rez Moss

誰もが経験したことがあるでしょう: APIリクエストを送信し、応答を待つと、ブラウザのコンソールに「CORSエラー」が表示されることがあります。

多くの開発者にとって、最初の本能は手っ取り早い解決策を見つけることです。「Access-Control-Allow-Origin: *」を追加して次に進む、といった具合に。しかし、そのアプローチは完全に的を外しています。CORSは単なる設定上の障害ではなく、これまでに構築された中で最も重要なブラウザセキュリティメカニズムの1つです。

CORS、すなわちCross-Origin Resource Sharingは、ウェブアプリケーション間の正当なクロスドメイン通信を許可しつつ、ユーザーを保護するために存在します。しかし、しばしば誤解され、誤って設定されたり、「バイパス」すべきバグとして扱われたりします。

しかし、もうそうではありません。

このガイドでは、基本を超えて掘り下げます。次のことを学びます:

  • CORSが存在する理由と、それが同一オリジンポリシー(SOP)からどのように進化したか
  • ブラウザとサーバーは実際にどのようにクロスオリジンアクセスをネゴシエートしますか?
  • CORS設定が「正しく見える」にもかかわらず、一部で失敗する原因は何ですか?
  • プリフライトリクエスト、認証情報、およびブラウザの癖を安全に処理する方法

最終的には、CORSの設定方法を知るだけでなく、その動作原理をなぜそうなるのか理解し、それを考慮してAPIを安全に設計する方法も理解できます。

CORSとは何ですか?(そしてなぜ存在するのか) 

CORSは、あるオリジンからのウェブアプリケーションが、別のオリジンからのリソースに安全にアクセスする方法を定義するブラウザのセキュリティ標準です。

CORSセキュリティを理解するには、まずなぜそれが作成されたのかを知る必要があります。

APIやマイクロサービスがウェブを席巻するずっと以前から、ブラウザは同一オリジンポリシー(SOP)と呼ばれるシンプルなルールに従っていました。

このポリシーは、ウェブページが同じオリジン(つまり、同じプロトコル、ドメイン、ポート)からのみデータを送受信できると規定していました。

例えば:

同一オリジン比較表

URL A URL B 同一オリジン?
https://example.com/api https://example.com/users ✅ はい
https://example.com http://example.com ❌ いいえ(プロトコルが異なります)
https://example.com https://api.example.com ❌ いいえ(ホストが異なります)
https://example.com https://example.com:8080 ❌ いいえ(ポートが異なります)

この制限は、ほとんどのウェブサイトがモノリシックであった初期のウェブでは完全に理にかなっていました。単一のサイトが、フロントエンド、バックエンド、およびアセットをすべて1つのドメインでホストしていたためです。

しかし、WebがAPI、マイクロサービス、サードパーティ連携とともに進化するにつれて、この同じルールが障壁となりました。開発者は、以下のような他のドメインと通信するためにフロントエンドアプリケーションを必要としました。

  • www.example.com が api.example.com と通信しています
  • アプリがCDNまたはアナリティクスエンドポイントに接続する場合
  • サードパーティAPI(StripeやGoogle Mapsなど)を呼び出すWebクライアント

同一オリジンポリシーは、現代の分散アーキテクチャを阻む壁となりました。

ここでCross-Origin Resource Sharing (CORS)が登場しました。

ブラウザの制限を完全に解除するのではなく、CORSはSOPの制御された緩和を導入しました。これにより、ブラウザとサーバーが安全に、かつ双方が同意した場合にのみ、ドメイン間で通信する安全な方法が確立されました。

次のように考えてください。SOPは誰も入れない施錠されたドアであり、CORSは同じドアですが、ゲストリストがあり、用心棒がIDをチェックします。

この柔軟性と保護のバランスが、CORS設定をすべてのモダンなWebアプリケーションにとって不可欠なものにしています。

同一オリジンポリシー(SOP)を理解する。

CORS設定についてさらに深く掘り下げる前に、その基盤である同一生成元ポリシー(SOP)を理解することが不可欠です。

既に述べたように、SOPはウェブ上の悪意ある動作に対するブラウザの最初の防御線です。これにより、あるウェブサイトが別のウェブサイトのデータに自由にアクセスするのを防ぎ、クッキー、認証トークン、個人情報などの機密情報が露呈するリスクを低減します。

実際にどのように機能するかというと、ウェブページがブラウザに読み込まれる際、プロトコル、ホスト、ポートの3つの要素に基づいてオリジンが割り当てられます。

https://   api.example.com   :443
^          ^                 ^
プロトコル   ホスト              ポート

2つのURLは、これら3つの部分すべてが一致する場合にのみ、同一オリジンと見なされます。そうでない場合、ブラウザはそれらをクロスオリジンとして扱います。

このシンプルなルールは、有害なクロスサイトアクションを阻止します。これがなければ、任意のサイトがオンラインバンキングのダッシュボードを不可視のフレームにロードし、残高を読み取り、同意なしに攻撃者に送信する可能性があります。

要するに、SOPは異なるサイト間のコンテンツを分離し、各オリジンが自己完結型のセキュリティゾーンであることを保証するために存在します。

SOPだけでは不十分だった理由

ウェブサイトが自己完結型であった頃、同一オリジンポリシーは完璧に機能しました。しかし、ウェブがAPI、マイクロサービス、分散アーキテクチャのエコシステムへと進化するにつれて、この厳格なルールは大きな制約となりました。

最新のアプリケーションには以下が必要でした:

  • 異なるサブドメインでホストされている独自のAPIを呼び出す(app.example.com → api.example.com)
  • CDNまたはサードパーティサービスからアセットを取得します
  • Stripe、Firebase、Google Mapsなどの外部APIと連携します。

SOPの下では、これらの正当なクロスオリジンリクエストはブロックされました。開発者はJSONP、リバースプロキシ、または重複ドメインなど、あらゆる回避策を試みましたが、これらの修正は安全でないか、または非常に複雑でした。

ここでCORS (Cross-Origin Resource Sharing) が状況を一変させました。

CORSは、ブラウザとサーバーが信頼を交渉できるハンドシェイクシステムを導入しました。SOPを破るのではなく、それを拡張し、クロスドメイン通信のために特定のオリジンを安全にホワイトリスト化する方法を提供しました。

CORSの仕組み:プロトコルレベルのフロー

既に述べたように、ブラウザが異なるオリジンにリクエストを行う際、それを無条件に送信するわけではありません。代わりに、明確に定義されたCORSプロトコルに従います。これは、リクエストが許可されるべきかを判断するために、ブラウザとサーバー間で交わされるやり取りです。

その核となるCORSは、HTTPヘッダーを介して機能します。ブラウザはすべてのクロスオリジンリクエストにOriginヘッダーを付加し、リクエストの送信元をサーバーに伝えます。その後、サーバーは1つ以上のAccess-Control-*ヘッダーで応答し、許可されている内容を定義します。

その会話の簡略化された例を以下に示します。

# Request
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

# Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"message": "Success"}
リクエスト&レスポンス CORSフロー
リクエスト&レスポンス CORSフロー

この場合、サーバーはオリジン https://app.example.com がそのリソースにアクセスすることを明示的に許可しています。ブラウザはこの応答をチェックし、一致を確認して、データをJavaScriptに配信します。

しかし、オリジンが一致しない場合や、レスポンスヘッダーが欠落しているか不正確な場合、ブラウザはサイレントにレスポンスをブロックします。データは表示されず、コンソールに表示されるのはあのイライラする「CORSエラー」メッセージだけです。

CORS自体がサーバーをより安全にするわけではないことに注意することが重要です。むしろ、ブラウザとサーバー間のエンゲージメントルールを強制し、信頼できるオリジンのみが保護されたリソースにアクセスできることを保証するセキュリティレイヤーとして機能します。

CORSリクエストの種類

CORSは、シンプルリクエストとプリフライトリクエストの2つの主要なリクエストタイプを定義しています。その違いは、ブラウザがデータを送信する前にどの程度の検証を行うかという点にあります。

1. シンプルなリクエスト

A シンプルリクエストは、最も簡単なタイプです。特定のルールに従っている限り、ブラウザによって自動的に許可されます。

  • これらのメソッドのいずれかを使用します:GET、HEAD、またはPOST

  • 特定のヘッダーのみを含みます:

    • 承認
    • Accept-Language
    • Content-Language
    • Content-Type(ただし、application/x-www-form-urlencoded、multipart/form-data、またはtext/plainのみ)
  • カスタムヘッダーやストリームは使用しません。

それがどのようなものかを示します。

# Request
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

# Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"message": "This is the response data"}

この場合:

  • ブラウザはOriginヘッダーを自動的に追加します。
  • サーバーは、一致するオリジンを持つAccess-Control-Allow-Originを返さなければなりません。
  • オリジンが一致しないか、存在しない場合、ブラウザは応答をブロックします。

2. プリフライトリクエスト

シンプルでないリクエストでは、さらに興味深いことが起こります。例えば、PUT、DELETEなどのメソッドや、Authorizationのようなカスタムヘッダーを使用する場合です。

実際の要求を送信する前に、ブラウザはOPTIONS要求を使用してプリフライトチェックを実行します。このステップにより、サーバーが意図された操作を明示的に許可していることが保証されます。

例を挙げます。

# Preflight Request
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

# Preflight Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT, POST, GET, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 3600

# Actual Request (only sent if preflight succeeds)
PUT /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer token123

{"data": "update this resource"}

このシーケンスでは:

  1. ブラウザは非シンプルリクエストを検出します。
  2. 実際のメソッドとヘッダーの許可を求めるプリフライトOPTIONSリクエストを送信します。
  3. サーバーは、許可するメソッド、ヘッダー、オリジンで応答します。
  4. プリフライトチェックが成功した場合、ブラウザは実際のリクエストを送信します。そうでない場合、それをブロックします。

CORSにおける認証情報の処理

クッキー、トークン、またはセッションベースのログインなど、認証を必要とするAPIを扱う場合、CORSの動作は異なります。

デフォルトでは、セキュリティ上の理由から、ブラウザはクロスオリジンリクエストを未認証として扱います。これは、クッキーやHTTP認証ヘッダーが自動的に含まれないことを意味します。

認証情報付きリクエストを安全に有効にするには、2つの重要なステップが連携する必要があります:

1. クライアントは明示的にクレデンシャルを許可する必要があります:

fetch('https://api.example.com/data', {
  credentials: 'include'
})

2. サーバーはそれらを明示的に許可する必要があります:

Access-Control-Allow-Credentials: true

しかし、落とし穴があり、しかもそれは大きなものです。

Access-Control-Allow-Credentialsがtrueに設定されている場合、Access-Control-Allow-Originでワイルドカード(*)を使用することはできません。試行すると、ブラウザは応答を拒否します。

なぜなら、すべてのオリジンが認証情報付きリクエストを送信することを許可すると、CORSセキュリティの目的全体が損なわれ、インターネット上のあらゆるサイトがユーザーのセッションに紐づくプライベートデータにアクセスできるようになるからです。

したがって、これの代わりに:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

常に特定のオリジンを使用する必要があります:

Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Credentials: true

APIが複数の信頼されたドメインにサービスを提供している場合、サーバー側で正しいオリジンヘッダーを動的に返すことができます。

const allowedOrigins = ['https://app1.com', 'https://app2.com'];
const origin = req.headers.origin;

if (allowedOrigins.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

このアプローチにより、認証されたリクエストは安全かつ意図的に保たれ、誰でも試せる状態にはなりません。

ブラウザはどのようにCORS対象のリクエストを判断しますか?

リクエストがサーバーに到達する前に、ブラウザはそれがCORSルールに該当するかどうかを決定します。

この決定は、リクエストの送信元と、それが別のドメイン、ポート、またはプロトコルをターゲットとしているかどうかに依存します。

例えば:

  • https://example.comで提供されるページからhttps://api.example.comをリクエストする: ✅ CORSが適用されます(異なるサブドメイン)。
  • https://example.comからhttps://example.com:3000をリクエストする場合: ✅ CORSが適用されます(異なるポート)。
  • 同じドメインとポートからhttps://example.comをリクエストする場合: ❌ CORSは適用されません。

ブラウザがリクエストがオリジンをまたがっていることを検出した場合、自動的にリクエストにOriginヘッダーを含めます。

オリジン: https://example.com

このヘッダーは、リクエストの送信元をサーバーに伝え、サーバーがアクセスを許可するかブロックするかを決定するために使用されます。

応答に適切なヘッダー(例:Access-Control-Allow-Origin)がない場合、サーバーが技術的には応答を送信していても、ブラウザは単に応答へのアクセスをブロックします。

これは重要な区別です。CORSを強制するのはブラウザであり、サーバーではありません。

内部セキュリティチェック、XMLHttpRequestとFetchの比較、およびブラウザの違い

すべてのブラウザがCORSを同じように処理するわけではありませんが、すべて同じセキュリティモデルに従います。明示的に許可されていない限り、クロスオリジンデータを信頼してはなりません。

異なるのは、規則をどれほど厳密に適用するか、そしてどのAPIに適用するかです。

1. 内部のCORSセキュリティチェック

ブラウザがクロスオリジンリクエストに対するレスポンスを受信すると、そのレスポンスをJavaScriptコードに公開する前に内部検証ステップを実行します。

次のようなヘッダーをチェックします。

  • Access-Control-Allow-Origin: リクエスト元のオリジンと一致する必要があります(または、場合によっては *)。
  • Access-Control-Allow-Credentials: クッキーまたは認証トークンが関与する場合、trueである必要があります。
  • Access-Control-Allow-Methods および Access-Control-Allow-Headers: プリフライトリクエストが送信された場合、元のプリフライトリクエストと一致する必要があります。

これらのチェックのいずれかが失敗した場合、ブラウザはHTTPエラーを発生させず、単に応答へのアクセスをブロックし、コンソールにCORSエラーを記録します。

これにより、実際のネットワークリクエストは成功しているにもかかわらず、ブラウザが安全のために結果を隠すため、デバッグが困難になります。

2. XMLHttpRequest vs. Fetch

XMLHttpRequestと最新のfetch() APIはどちらもCORSをサポートしていますが、認証情報とデフォルトに関してはわずかに異なる動作をします。

XMLHttpRequestを使用すると:

  • クッキーとHTTP認証は、withCredentialsがtrueに設定されている場合、自動的に送信されます。
  • プリフライトの動作は、カスタムヘッダーが追加されるかどうかに依存します。

Fetchを使用すると:

  • 認証情報(Cookie、HTTP認証)は、デフォルトでは含まれません
  • これらを明示的に有効にするには、以下を使用する必要があります:
fetch("https://api.example.com/data", {
	credentials: "include"
});
  • fetchはCORS下でリダイレクトをより厳密に扱います。これは、許可されていない限りクロスオリジンリダイレクトを追跡しないためです。

したがって、fetchはよりクリーンでモダンですが、ヘッダーを忘れたり、認証情報ルールを見落としたりした場合には、あまり寛容ではありません。

3. ブラウザの違いと特性

CORS仕様は標準ですが、ブラウザは微妙な違いをもって実装しています。

  • Safariは、特にサードパーティクッキーがブロックされている場合、クッキーや認証情報付きリクエストに対して過度に厳格になることがあります。
  • Firefoxは、予期せぬほど長く失敗したプリフライト応答をキャッシュすることがあり、テスト中に一貫性のない結果を招きます。
  • Chromeは、特定のリダイレクトチェーンにおいて、他のブラウザよりも厳しくCORSを適用します。

これらの違いにより、あるブラウザでは完璧に機能する設定が、別のブラウザではサイレントに失敗する可能性があります。

だからこそ、特に認証情報やリダイレクトが関わる場合、ブラウザ間でCORSの設定をテストすることが重要です。

Originヘッダーのサーバーサイド処理

ブラウザはCORSを強制しますが、実際の意思決定はサーバー側で行われます。

ブラウザがクロスオリジンリクエストを送信する際、常にOriginヘッダーを含みます。サーバーの役割は、そのヘッダーを検査し、許可するかどうかを決定し、応答として正しいCORSヘッダーを返すことです。

1. オリジンを検証する

一般的なリクエストは次のようになります。Origin: https:\/\/frontend.example.com

サーバー側では、コードがこのオリジンが許可されているかどうかを確認する必要があります。最もシンプル(かつ安全)なアプローチは、信頼できるドメインの許可リストを維持することです。

const allowedOrigins = ["https://frontend.example.com", "https://admin.example.com"];
if (allowedOrigins.includes(req.headers.origin)) {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
}

これにより、既知のクライアントのみがAPIにアクセスでき、それ以外のクライアントはCORS許可を受けられなくなります。

APIがクッキー、トークン、またはその他の認証情報を扱う場合、Access-Control-Allow-Origin: * を返さないでください。

2. プリフライトリクエストの処理

OPTIONSプリフライトリクエストの場合、サーバーはメインリクエストと同じ注意を払って応答する必要があります。
完全なプリフライト応答には以下が含まれます。

Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

これらのヘッダーは、何が許可され、その決定をどのくらいの期間キャッシュできるかをブラウザに伝えます。いずれかが欠落しているか誤っている場合、エンドポイント自体が正常に機能していても、ブラウザは後続のリクエストをブロックします。

3. 動的なCORSヘッダーの設定

大規模なシステム(マルチテナントプラットフォームや複数のクライアントを持つAPIなど)では、許可されたオリジンは動的である必要がある場合があります。

例えば:

const origin = req.headers.origin;

if (origin && origin.endsWith(".trustedclient.com")) {
  res.setHeader("Access-Control-Allow-Origin", origin);
}

このパターンにより、信頼されたドメインのすべてのサブドメインが許可され、同時に未知のソースはフィルタリングされます。

オリジンを慎重に検証し、制約なしにユーザー入力を文字列照合しないようにしてください。そうしないと、攻撃者が有効に見えるヘッダーを偽造する可能性があります。

4. 「Postmanで動作する」ことが正しく設定されていることを意味しない理由

CORSに関する最大の誤解の1つは、「Postmanでは動作するから、ブラウザの問題に違いない」というものです。

PostmanはCORSを一切強制しません。ブラウザではないためです。

それは、Access-Control-*ヘッダーがない完全にオープンなAPIでも、そこでは問題なく応答するが、ChromeやFirefoxではすぐに失敗することを意味します。

したがって、APIがPostmanでは機能するが、Webアプリでは機能しない場合、CORSヘッダーが不完全であるか、誤って設定されている可能性があります。

一般的なCORSの誤設定(およびその回避方法)

1. Access-Control-Allow-Origin: * を資格情報と共に使用する

これは最も頻繁で危険な間違いです。
もし応答に両方が含まれている場合:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

…ブラウザはリクエストを自動的にブロックします。

CORSの仕様では、資格情報が含まれる場合にワイルドカードを使用することを禁止しています。これは、任意のサイトがクッキーや認証トークンに紐付けられたユーザーデータにアクセスできるようになるためです。

対策: 資格情報が使用される場合は、常に特定のオリジンを返します。

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

2. プリフライトリクエストの処理を忘れる

多くのAPIはGETとPOSTには正しく応答しますが、OPTIONSプリフライトリクエストについては見落としがちです。

そのような場合、ブラウザは実際のエンドポイントに到達せず、プリフライトの失敗後、メインリクエストをブロックします。

対策: OPTIONSリクエストを明示的に処理し、適切なヘッダーで応答します。

if (req.method === "OPTIONS") {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
  return res.sendStatus(204);
}

3. 不一致のリクエストおよびレスポンスヘッダー

もう一つの微妙な問題は、プリフライトリクエストが特定のヘッダーを要求する可能性があるにもかかわらず、サーバーがそれらを明示的に許可していないことです。

例えば、リクエストに以下が含まれる場合:

Access-Control-Request-Headers: Authorization, Content-Type

…しかし、サーバーは以下のみで応答します:

Access-Control-Allow-Headers: Content-Type

…ブラウザはそれをブロックします。両方のリストは完全に一致する必要があります。

対策: Access-Control-Allow-Headersに、クライアントが送信する可能性のあるすべてのヘッダー、特にAuthorization、Accept、およびカスタムヘッダーが含まれていることを確認してください。

4. 複数のAccess-Control-Allow-Originヘッダーの返却

設定が誤っている一部のプロキシまたはフレームワークは、複数のAccess-Control-Allow-Originヘッダー(例えば、静的な * と動的なオリジンが1つずつ)を送信します。

ブラウザはそれを無効とみなし、リクエスト全体をブロックします。

対策: 常に単一の有効なAccess-Control-Allow-Originヘッダーを返します。

5. メソッド制限を忘れること

Access-Control-Allow-Methodsに許可されたすべてのメソッドが含まれていない場合、ブラウザは正当なリクエストを拒否します。

例えば、APIがPUTをサポートしているにもかかわらず、プリフライト応答がGETとPOSTのみを許可する場合があります。

修正: サポートされているすべてのメソッドをリストアップするか、APIルートを動的にマッチングさせて一貫性を確保してください。

6. キャッシュされたプリフライト応答の無視

最新のブラウザは、パフォーマンス向上のためにプリフライト結果をキャッシュします。
しかし、サーバーやCDNがOriginによって変化させずにレスポンスをキャッシュすると、誤って別のクライアントに間違ったCORSヘッダーを送信してしまう可能性があります。

対策: Vary: Originヘッダーを使用して、オリジンごとにレスポンスが個別にキャッシュされるようにします。

CORSの問題は、一つの大きな間違いから生じることはめったにありません。通常、ブラウザの期待とサーバー設定の間のいくつかの小さな不一致の結果です。これらのパターンを理解することで、終わりのない「CORSエラー」デバッグサイクルを回避できます。

CORSは敵ではありません、それを誤解することが敵です

一見すると、CORSは不要な障壁のように、あるいはリクエストを中断させ開発を遅らせるゲートキーパーのように感じられます。

しかし実際には、これはこれまで構築された中で最も重要なブラウザセキュリティ機能の一つです。

仕組みを理解すると、「CORSエラー」を単なるランダムな障害として捉えるのではなく、クライアントとサーバーが信頼、ヘッダー、または認証情報に関してより適切に連携する必要があることを示すシグナルとして認識するようになります。

シングルページアプリケーションを構築している場合でも、分散型APIエコシステムを構築している場合でも、CORSは安全なクロスドメイン通信を可能にしながら、ユーザーを保護するための味方です。

次にそのおなじみのコンソールメッセージに遭遇したときは、ワイルドカードに手を伸ばさないでください。ヘッダーを読み、ロジックをトレースし、ランダムなハックではなく、自身の理解に基づいて修正を導いてください!

共有:

https://www.aikido.dev/blog/cors-security-beyond-basic-configuration

脅威ニュースをサブスクライブ

今日から無料で始めましょう。

無料で始める
CC不要

今すぐ、安全な環境へ。

コード、クラウド、ランタイムを1つの中央システムでセキュアに。
脆弱性を迅速に発見し、自動的に修正。

クレジットカードは不要です | スキャン結果は32秒で表示されます。