オリジンと CORS — ブラウザが何を境界にして遮るか
CORS エラーに出くわしたとき、多くの人が「サーバーがリクエストを拒否した」と読みます。実際にはサーバーは正常に処理して 200 を返していることがほとんどで、捨てているのはブラウザです。
この誤読が起きるのは、同一オリジンポリシーが何を遮っているかが直感と違うからです。遮るのは読み取りで、送信は遮りません。
この章で学ぶこと
- オリジンが scheme + host + port の 3 つ組であること
- 同一オリジンポリシーが遮るのは読み取りで、送信は遮らない。だから CSRF が成立する
- プリフライトが飛ぶ条件と、飛ばないリクエストの範囲
- CORS エラーを「どこで落ちたか」で切り分ける手順
オリジンとは何か
オリジンは 3 つの要素で決まります (RFC 6454 が定義しています)。
https :// example.com : 443
scheme host port
3 つすべてが一致して初めて同一オリジンです。
| URL | https://example.com/a と同一か | 違う要素 |
|---|---|---|
https://example.com/b | 同一 | パスは無関係 |
http://example.com/a | 別 | scheme |
https://api.example.com/a | 別 | host |
https://example.com:8443/a | 別 | port |
port が入ることを忘れやすい点です。ローカル開発で localhost:3000 から localhost:8080 の API を叩くと、これはクロスオリジンです。
「オリジンサーバー」とは別の概念
HTTP キャッシュと CDN に出てくる「オリジンサーバー」は、キャッシュに対する本家のサーバーという意味です。
| 用語 | 意味 | 文脈 |
|---|---|---|
| オリジン | scheme + host + port の 3 つ組 | ブラウザのセキュリティ境界 |
| オリジンサーバー | キャッシュではなく本体を持つサーバー | CDN とキャッシュの階層 |
語源は同じですが、判断に使う場面がまったく違います。混同すると「CDN を挟むとオリジンが変わるのか」のような噛み合わない問いになります。CDN が同じホスト名で配信するなら、ブラウザから見たオリジンは変わりません。
同一オリジンポリシーは何を遮るか
**遮るのは他オリジンのレスポンスを読むことです。**送ること自体は遮りません。
| 操作 | 他オリジンに対して |
|---|---|
<img src> で画像を読み込む | できる (表示はできるが画素は読めない) |
<form> を submit する | できる |
<script src> で読み込んで実行する | できる |
fetch でレスポンスの本文を読む | できない (CORS の許可が要る) |
document の中身を読む | できない |
この非対称が CSRF の成立条件そのものです。攻撃者のサイトからフォームを submit できて、しかも クッキーは自動で付く。レスポンスは読めませんが、状態は変えられます。
一方で fetch に独自ヘッダーを付けると、それはプリフライトを要求します。サーバーが許可しない限り本リクエストが飛びません。だから「独自ヘッダーの存在を確認する」が CSRF 対策として機能します。フォーム送信は遮られないのに独自ヘッダーは遮られるという非対称を使っているわけです。
CORS は制限を緩める仕組み
CORS (Cross-Origin Resource Sharing) は制限を追加するものではありません。既定で禁止されている読み取りを、サーバーの許可があるときだけ通す仕組みです。
許可はレスポンスヘッダーで表明します。
| ヘッダー | 意味 |
|---|---|
Access-Control-Allow-Origin | 読み取りを許すオリジン。* または具体的な 1 つ |
Access-Control-Allow-Credentials | 資格情報付きのリクエストを許すか |
Access-Control-Allow-Methods | プリフライトへの応答。許すメソッド |
Access-Control-Allow-Headers | プリフライトへの応答。許すリクエストヘッダー |
Access-Control-Max-Age | プリフライトの結果をキャッシュする秒数 |
Access-Control-Expose-Headers | JavaScript から読ませるレスポンスヘッダー |
最後の 1 つは見落とされます。既定では JavaScript が読めるレスポンスヘッダーは限られており、X-Total-Count のような独自ヘッダーはページネーションで必要でも読めません。Expose-Headers に列挙して初めて読めます。
プリフライト
条件によって、本リクエストの前に OPTIONS が飛びます。
飛ばないのは、フォームや画像で昔からできたことと同じ範囲に収まるリクエストだけです。
| 条件 | プリフライトなしで通る範囲 |
|---|---|
| メソッド | GET / HEAD / POST |
| ヘッダー | 特定の少数のみ (Accept / Accept-Language / Content-Language / Content-Type ほか) |
Content-Type | text/plain / multipart/form-data / application/x-www-form-urlencoded |
Content-Type: application/json はこの範囲を外れます。JSON を POST するとプリフライトが飛ぶのはこのためです。フォームでは送れない形式なので、既定で許されていた範囲の外にあります。
Authorization ヘッダーも範囲外です。認証付きの API はほぼすべてプリフライトを通ります。
ワイルドカードと資格情報は併用できない
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
この組み合わせはブラウザが拒否します。資格情報 (クッキーや Authorization) を伴うリクエストでは、Allow-Origin に具体的なオリジンを 1 つ書く必要があります。
理由は明白です。* は「誰が読んでもよい」という宣言ですが、資格情報付きのレスポンスにはその利用者だけの情報が入っています。誰でも読めるわけがありません。
具体的なオリジンを 1 つしか書けないので、複数のオリジンを許可するにはリクエストの Origin を見て動的に返します。ここで許可リストとの照合を省くと、任意のオリジンに許可を出すことになります。
動的に返すなら Vary: Origin が要る
Origin を見て Allow-Origin を変えるレスポンスは、オリジンごとに内容が違うことになります。これを共有キャッシュに載せると事故が起きます。
1. app.example.com からのリクエスト
→ Access-Control-Allow-Origin: https://app.example.com が CDN に保存される
2. admin.example.com からのリクエスト
→ CDN が 1 のレスポンスを返す
→ Allow-Origin が app.example.com なのでブラウザが拒否
Vary: Origin を付けると、CDN は Origin ごとに別のキャッシュとして扱います。これは HTTP キャッシュと CDN の「ユーザーごとに内容が違うレスポンスを共有キャッシュに載せると他人のデータが配られる」と同じ構図です。配られるのが個人データではなく許可ヘッダーである、という違いだけです。
CORS エラーを切り分ける
DevTools の Network タブで、どこで落ちたかを先に決めます。
| 観測できる事実 | 落ちた場所 | 疑うもの |
|---|---|---|
OPTIONS が 4xx / 5xx | プリフライト | サーバーが OPTIONS を捌いていない (ルーティング・認証ミドルウェア) |
OPTIONS は 200 だが本リクエストが飛ばない | プリフライトの応答内容 | Allow-Methods / Allow-Headers に必要な値が無い |
| 本リクエストが 200 なのにエラー | レスポンスヘッダー | Allow-Origin が無い、または値が一致しない |
| 資格情報付きだけ失敗する | 組み合わせ | Allow-Origin: * と Allow-Credentials: true の併用 |
ヘッダーが undefined になる | 読み取り制限 | Expose-Headers に列挙していない |
3 行目が最も混乱を招きます。サーバーは正常に処理を終えています。DELETE なら削除は実行済みです。ブラウザがレスポンスを読ませないだけなので、「エラーなのに消えている」という状況になります。
サーバー側のログを見ても異常が出ないのはこのためです。原因はレスポンスヘッダーの不足で、リクエストの処理ではありません。
よくある誤解
「CORS はサーバーを守る仕組み」 — 守っているのはユーザーのブラウザです。サーバーを守るのは認証と認可です。CORS の許可を出しても、認証が要るエンドポイントは認証で守られます。
「CORS エラーはサーバーが拒否している」 — サーバーは処理を終えていることがほとんどです。捨てているのはブラウザです。
「同一オリジンポリシーがあれば CSRF は起きない」 — 起きます。同一オリジンポリシーは読み取りを遮るだけで、送信は遮りません。
「ポートが違っても同じホストなら同じオリジン」 — 別オリジンです。port も 3 つ組の一部です。
「Allow-Origin: * にすればすべて解決する」 — 資格情報付きのリクエストでは使えません。認証付き API では必ず具体的なオリジンが要ります。
「サーバー間の通信も CORS の制約を受ける」 — 受けません。同一オリジンポリシーはブラウザが実装している制限です。curl やサーバー間の HTTP クライアントには無関係です。
確認問題
問 1. DELETE /api/orders/1 を叩くと CORS エラーになりますが、リロードすると注文は消えています。何が起きていますか。
答え: プリフライトは通り、本リクエストも成功していて、レスポンスの Access-Control-Allow-Origin が足りません。
処理の順序で追います。
OPTIONSが飛び、サーバーがAllow-Methods: DELETEを返した → プリフライトは通過DELETEが飛び、サーバーが注文を削除して 200 を返した → 処理は完了している- そのレスポンスに
Allow-Originが無い (または値が一致しない) → ブラウザが読ませない
つまり OPTIONS にだけ CORS ヘッダーを付けて、本レスポンスに付け忘れているという設定ミスです。ミドルウェアの適用範囲が OPTIONS だけになっている場合に起きます。
見分け方は Network タブで DELETE のステータスを見ることです。200 が返っていれば処理は済んでいます。
問 2. 一覧 API のレスポンスヘッダーに総件数を入れましたが、JavaScript から読めません。ヘッダーは Network タブに見えています。
答え: Access-Control-Expose-Headers に列挙していません。
クロスオリジンのレスポンスで JavaScript が読めるヘッダーは既定で限られています。独自のヘッダー (X-Total-Count など) は、Network タブには見えていても response.headers.get() では null になります。
Access-Control-Expose-Headers: X-Total-Count
DevTools はブラウザ自身の機能なので制限を受けません。見えていることと読めることが別という点が引っかかりどころです。
同一オリジンなら制限がないので、開発環境でプロキシを通していると気づかず、本番で初めて出る種類の問題です。
問 3. CDN の背後に置いた API で、あるユーザーだけ CORS エラーが出ます。時間を置くと直り、また再発します。
答え: Origin を見て Allow-Origin を返しているのに Vary: Origin を付けていません。
CDN は最初に来たリクエストのレスポンスを保存し、以降の同じ URL への要求に配ります。そのレスポンスに埋まっている Allow-Origin は最初のリクエスト元のオリジンです。
app.example.com からの要求 → Allow-Origin: https://app.example.com がキャッシュされる
admin.example.com からの要求 → 上記が配られる → オリジンが一致せずエラー
「時間を置くと直る」のはキャッシュが期限切れになり、次に来た側のオリジンで再取得されるからです。そのためどちらのオリジンが失敗するかが入れ替わります。
Vary: Origin を付けると、CDN は Origin ごとに別のキャッシュとして扱います。
この事故が HTTP キャッシュと CDN の「ユーザー固有のレスポンスを共有キャッシュに載せる」と同型であることに注意します。共有キャッシュに載せてよいのは、誰に対しても同じ内容のレスポンスだけという原則が、CORS ヘッダーにも適用されます。
まとめ
- オリジンは scheme + host + port の 3 つ組。port も含まれる
- 同一オリジンポリシーが遮るのは読み取り。送信は遮らないので CSRF が成立する
- CORS は制限を追加するのではなく、既定の禁止をサーバーの許可で緩める仕組み
- プリフライトが飛ぶのは「フォームや画像で昔からできた範囲」を外れるとき。
application/jsonの POST は外れる Allow-Origin: *とAllow-Credentials: trueは併用できないOriginを見て返すならVary: Originが要る。忘れると CDN が他オリジン向けの許可を配る- CORS エラーでもサーバーの処理は完了していることが多い。切り分けは「どこで落ちたか」から始める
- Laravel API 開発ガイド — ミドルウェアとレート制限 — フレームワークでの CORS 設定と実装
- MDN — オリジン間リソース共有 (CORS)
- RFC 6454 — The Web Origin Concept — オリジンの定義
- Fetch Standard — CORS protocol — プリフライトの条件とヘッダーの正本
次に読む
- TLS と証明書 — 同じ通信路にあるもう 1 つの境界
- Web アプリの主要な攻撃と対策 — 送信が遮られないことを悪用する CSRF
- HTTP キャッシュと CDN —
Varyを落としたときに共有キャッシュで何が起きるか