メインコンテンツまでスキップ

オリジンと CORS — ブラウザが何を境界にして遮るか

CORS エラーに出くわしたとき、多くの人が「サーバーがリクエストを拒否した」と読みます。実際にはサーバーは正常に処理して 200 を返していることがほとんどで、捨てているのはブラウザです。

この誤読が起きるのは、同一オリジンポリシーが何を遮っているかが直感と違うからです。遮るのは読み取りで、送信は遮りません。

この章で学ぶこと

  • オリジンが scheme + host + port の 3 つ組であること
  • 同一オリジンポリシーが遮るのは読み取りで、送信は遮らない。だから CSRF が成立する
  • プリフライトが飛ぶ条件と、飛ばないリクエストの範囲
  • CORS エラーを「どこで落ちたか」で切り分ける手順
前提知識

HTTP のヘッダーと クッキー の自動送出を前提にします。

オリジンとは何か

オリジンは 3 つの要素で決まります (RFC 6454 が定義しています)。

https :// example.com : 443
scheme host port

3 つすべてが一致して初めて同一オリジンです。

URLhttps://example.com/a と同一か違う要素
https://example.com/b同一パスは無関係
http://example.com/ascheme
https://api.example.com/ahost
https://example.com:8443/aport

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-HeadersJavaScript から読ませるレスポンスヘッダー

最後の 1 つは見落とされます。既定では JavaScript が読めるレスポンスヘッダーは限られておりX-Total-Count のような独自ヘッダーはページネーションで必要でも読めません。Expose-Headers に列挙して初めて読めます。

プリフライト

条件によって、本リクエストの前に OPTIONS が飛びます。

飛ばないのは、フォームや画像で昔からできたことと同じ範囲に収まるリクエストだけです。

条件プリフライトなしで通る範囲
メソッドGET / HEAD / POST
ヘッダー特定の少数のみ (Accept / Accept-Language / Content-Language / Content-Type ほか)
Content-Typetext/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 が足りません。

処理の順序で追います。

  1. OPTIONS が飛び、サーバーが Allow-Methods: DELETE を返した → プリフライトは通過
  2. DELETE が飛び、サーバーが注文を削除して 200 を返した → 処理は完了している
  3. そのレスポンスに 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 エラーでもサーバーの処理は完了していることが多い。切り分けは「どこで落ちたか」から始める
関連リファレンス

次に読む