CORS が分かりにくいのは、禁止する仕組みと、禁止を解除する仕組みが別々にあるから。土台の禁止が同一オリジンポリシー、それを部分的に開けるのが CORS です。順番に分ければ、あのエラーメッセージも読めるようになります。
同一オリジンポリシー。別オリジンのレスポンスを JavaScript から読むのは禁止。
サーバーが「このオリジンには読ませていい」とヘッダで表明する。制限ではなく緩和。
サーバーは許可証を返すだけ。遮断はブラウザがやる。だから curl では起きない。
この3つのうち、実務で一番踏むのが Rule 03 です。「サーバーがブロックしている」と誤診すると、いつまでも直りません。
オリジンとは、URL のうちスキーム・ホスト・ポートの3つを取り出したもの。3つ全部が一致して初めて「同一オリジン」です。パスやクエリは関係ありません。
https://app.example.com を基準にすると、判定はこうなります。
| 比較する URL | 判定 | 理由 |
|---|---|---|
https://app.example.com/other/page | 同一 | パスが違うだけ |
http://app.example.com | 別 | スキームが違う |
https://api.example.com | 別 | ホストが違う(サブドメインも別扱い) |
https://app.example.com:8443 | 別 | ポートが違う |
https://example.com | 別 | ホストが違う |
フロントとAPIをサブドメインで分けた瞬間に CORS の話が始まるのは、3行目が理由です。
ブラウザには最初から「別オリジンのレスポンスの中身を JavaScript に渡さない」という禁止が入っています。これが同一オリジンポリシー。なぜ必要かは、無かった場合を想像すると分かります。
Cookie は宛先のドメイン宛なら自動で付くという性質があります。あなたが銀行にログイン済みなら、銀行宛のリクエストにはセッション Cookie が勝手に付きます。誰がそのリクエストを出させたかは関係ありません。
SOP は「リクエストを送らせない」仕組みではありません。返事を読ませないだけです。だから、読めなくても目的が達成できる攻撃 —— たとえば「送金 API を叩くだけでいい」というケース —— は SOP では防げません。それが CSRF です。
「CORS があるから CSRF は起きない」という説明を見かけたら、それは間違いだと思ってください。
ただ、SOP は厳しすぎます。自社の app.example.com から自社の api.example.com を呼ぶのも、ルール上は「別オリジン」で禁止対象。これでは困るので、サーバー側が明示的に例外を宣言する方法が用意されました。それが CORS(Cross-Origin Resource Sharing)です。
やりとりは単純です。ブラウザが「自分はどこの誰か」を Origin ヘッダで名乗り、サーバーが「そこには読ませていい」を Access-Control-Allow-Origin ヘッダで返す。ブラウザは2つを照合して、JS に渡すかどうかを決めます。
curl や Postman には「オリジン」という概念がありません。SOP はブラウザが自分の中で守っているルールなので、ブラウザの外には存在しない。CORS エラーはサーバーが返した失敗ではなく、ブラウザが下した判断です。だから「curl だと動くのにブラウザだと動かない」は異常ではなく、当たり前の挙動です。
| ヘッダ | 誰が送る | 意味 |
|---|---|---|
Origin | ブラウザ | 「この要求はどこのページから出たか」。JS では書き換えられない |
Access-Control-Allow-Origin | サーバー | 読み取りを許すオリジン。1つだけ、または * |
Access-Control-Allow-Methods | サーバー | 許可するメソッド(プリフライトの応答で使う) |
Access-Control-Allow-Headers | サーバー | 許可する独自ヘッダ(Authorization など) |
Access-Control-Allow-Credentials | サーバー | true なら Cookie 付きの要求を許す |
Access-Control-Expose-Headers | サーバー | JS から読ませるレスポンスヘッダの追加指定 |
Access-Control-Max-Age | サーバー | プリフライト結果をキャッシュする秒数 |
Access-Control-Allow-Origin にカンマ区切りで複数書くことはできません。複数のオリジンを許したいときは、サーバー側で Origin を見て、許可リストにあればその1つだけを返す実装にします。この実装がのちに落とし穴になります(7章)。
DevTools を見ていて、1回のはずの通信が OPTIONS と本番の2回に見えることがあります。あれがプリフライトリクエストです。
理由は、Fig 2 の状況にあります。リクエストは送信された時点でサーバーに届いてしまうので、DELETE のように副作用のある要求は「届いた時点で手遅れ」になりかねない。そこでブラウザは、本番の前に許可を確認しにいきます。
Max-Age が効いている間は下見を省略するので、毎回2往復になるわけではない。次のすべてを満たすものだけが「単純なリクエスト」として1往復で済みます。1つでも外れると下見が入ります。
GET / HEAD / POST のどれかContent-Type が application/x-www-form-urlencoded / multipart/form-data / text/plain のどれかよくある「JSON を POST したらプリフライトが出た」は、2つ目に引っかかったケースです。Content-Type: application/json は単純リクエストの条件に入っていません。Authorization ヘッダを足した場合も同じく3つ目で外れます。
CORS では、デフォルトで Cookie は送られません。送るには両側の合意が要ります。
| 側 | 必要な設定 |
|---|---|
| ブラウザ(JS) | fetch(url, { credentials: 'include' }) |
| サーバー | Access-Control-Allow-Credentials: true |
| サーバー | Access-Control-Allow-Origin に * が使えない。具体的なオリジンを返す |
* が禁止なのか
* は「どのサイトから読まれても構わない」という宣言です。そこに Cookie が付くと「どのサイトからでも、ログイン済みのあなたとして読める」ことになる。Fig 2 の攻撃がそのまま成立してしまうので、仕様のレベルで組み合わせが禁止されています。
コンソールの文言はほぼ定型なので、対応表を持っておくと速いです。直す場所はほぼ全部サーバー側です。
| 出る文言(抜粋) | 起きていること |
|---|---|
No 'Access-Control-Allow-Origin' header is present | サーバーが許可ヘッダを返していない。CORS 設定そのものが入っていない |
...header has a value ... that is not equal to the supplied origin | 許可リストに自分のオリジンが無い。末尾スラッシュや http/https 違いも多い |
Response to preflight request doesn't pass access control check | OPTIONS が 401 や 404 を返している。認証やルーティングが OPTIONS を弾いている |
Method DELETE is not allowed by Access-Control-Allow-Methods | メソッドの許可漏れ |
Request header field authorization is not allowed by Access-Control-Allow-Headers | 独自ヘッダの許可漏れ |
...must not be the wildcard '*' when the request's credentials mode is 'include' | * と Cookie の併用。5章の禁止に抵触 |
まず curl -I -H "Origin: https://app.example.com" <URL> を打って、レスポンスヘッダに Access-Control-Allow-Origin が付くか見る。付いていなければサーバーの設定漏れ、付いているのにブラウザで落ちるなら値の不一致か credentials 絡み。ここで2択に割れます。
CORS の設定は、緩めた瞬間に SOP という防壁に穴を開ける行為です。診断で必ず見るのが次の3つ。
「複数オリジンを許したい」という要求に対して、受け取った Origin をそのままエコーする実装をしてしまうケース。これに Allow-Credentials: true が組み合わさると、どのサイトからでも、被害者のセッションで API を読める状態になります。
$ curl -I -H "Origin: https://evil.example" https://api.target.com/v1/me HTTP/2 200 access-control-allow-origin: https://evil.example # ← 送った値がそのまま返ってきた access-control-allow-credentials: true # ← Cookie も許可されている # この2行が揃った時点で、罠サイトに仕込んだJSから # 被害者のログイン状態で /v1/me を読み、中身を攻撃者サーバーへ送れる。
null を許可しているOrigin: null は、サンドボックス化した iframe やリダイレクト経由で攻撃者が意図的に作り出せます。「null なら内部からだろう」と許可リストに入れるのは危険です。
許可判定を部分一致や前方一致で書くと、こうなります。
| 判定の書き方 | 通ってしまうオリジン |
|---|---|
origin.endsWith("example.com") | https://evil-example.com |
origin.includes("example.com") | https://example.com.evil.io |
正規表現 /example\.com/ | ドットのエスケープ漏れ・アンカー漏れで同上 |
対策は単純で、許可リストへの完全一致にすること。動的に組み立てないのが一番安全です。
Allow-Origin が要求の反射になっていないか/null を許していないか/* と Allow-Credentials が同居していないか。
この3点は、Web 診断でも設定レビューでも最初に見るところです。リクエストの Origin を書き換えて叩き、レスポンスヘッダが追従するかを見れば一発で分かります。
| 名前 | 何のための仕組みか | 誰が決める |
|---|---|---|
| SOP(同一オリジンポリシー) | 別オリジンのレスポンスを JS に読ませない、既定の壁 | ブラウザ(常時有効) |
| CORS | その壁に、指定したオリジンだけ窓を開ける | サーバーが宣言、ブラウザが判定 |
| CSRF | 仕組みではなく攻撃の名前。意図しない要求を送らされる | — |
| SameSite | 他サイト起点の要求に Cookie を付けない設定。CSRF の主対策 | サーバーが Cookie 属性で指定 |
| CSP | 自分のページがどこから何を読み込んでよいかの制限。XSS 対策寄り | サーバーがヘッダで指定 |
CORS と CSRF が特に混ざりやすいですが、向きが逆です。CORS は「読み取り」を許すかどうかの話、CSRF は「送信」が勝手に起きる話。読めなくても送れてしまうのが CSRF なので、CORS をどれだけ厳しくしても CSRF は防げません。
Access-Control-Allow-Origin で例外を宣言する。制限ではなく緩和。OPTIONS で下見してから送る。JSON POST や独自ヘッダで発動。Allow-Credentials: true。認証済みデータが他サイトから読める。