Dev Study
← Webセキュリティの実務
レッスン 2 / 12·約 9 分·第19期 Webセキュリティの実務

単純な要求で CORS が止めるのは、送ることではなく読むこと

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回は、Cookie が頼んだページに関係なく付いていくことを見ました。今回は、ブラウザが強制する代表例の CORS が、何を止めて何を止めないかを実測で見ます。

まず結論

CORS エラーは「要求が届かなかった」という意味ではありません。単純な要求なら、サーバには届いて処理され、ブラウザはその応答をスクリプトに渡さないだけです。

ブラウザが確かめるのは「応答をどこのページに見せてよいか」までで、要求を処理してよいか、つまり本人が望んだかどうかは、サーバが自分で確かめるしかありません。

? 読む前に予測

a.test のページのスクリプトが b.test のカウンタに POST しました(Content-Type は text/plain)。b.test は CORS の許可ヘッダを一切返しません。スクリプトには TypeError が返りました。b.test のカウンタはどうなったでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

鍵がかかっているのは、応答を読む側

CORS エラーを見ると「ブラウザが要求を止めた」と読みたくなります。仕様は CORS を別のものとして書いています。Fetch Standard §3.3 の冒頭です(Living Standard、Last Updated 21 September 2026。2026-09-24 取得)。

It is layered on top of HTTP and allows responses to declare they can be shared with other origins.

宣言するのは応答で、宣言の中身は共有してよいかです。許可を opt-in にする理由も、同じ節が書いています。

It needs to be an opt-in mechanism to prevent leaking data from responses behind a firewall (intranets).

守りたいのはファイアウォールの内側から返る応答の中身です。ブラウザは社内網の中にいることがあり、Cookie も持っています。任意のページのスクリプトがその応答を読めたら、そのページはブラウザの位置を借りて中身を持ち出せます。だから鍵は読む側にかかります。理由に CSRF(別サイトからの状態変更)は挙がっていません。

単純な要求で CORS エラーになるまでの順序
a.test のスクリプトb.test へ POST
送られる(Origin: a.test が付く)
b.test のサーバ処理してカウンタ +1
応答(許可ヘッダなし)
ブラウザの CORS check応答をスクリプトに渡さない
スクリプトに返るもの
TypeError読めなかった、という知らせだけ
  • ・止まるのは最後の段だけ。サーバの処理は、判定より前にもう終わっている
  • ・プリフライトが挟まる要求では、本体の前に OPTIONS で確認される
具体実物

許可ヘッダを返さないサーバに、POST してみる

手元で確かめました。自前の node サーバを 127.0.0.1 だけで待ち受けさせ、同じサーバ内の CONNECT プロキシでどのホスト名も 127.0.0.1 に流して、a.test と b.test を別サイトに見せています。ブラウザは Playwright 同梱の Chromium 153.0.8010.12・Firefox 155.0・WebKit 26.6(headless)で、市販のブラウザそのものではありません。

b.test の /counter は POST が来るとカウンタを1増やし、CORS の応答ヘッダは一切返しません。手順は (1) b.test 自身でカウンタを読む (2) a.test のページのスクリプトから text/plain で POST (3) b.test 自身でもう一度読む、です。

Chromium 1回目(out-2-chromium-1.txt より、スクリプト側の出力行)コマンド出力
[chromium 153.0.8010.12] before (read on b.test itself): count=7
[chromium 153.0.8010.12] a.test script fetch POST b.test/counter text/plain : rejected TypeError: Failed to fetch
[chromium 153.0.8010.12] after (read on b.test itself): count=8

同じ回のサーバのログには、POST の行がこう残っています(生ログ1行から該当項目だけを抜き出して並べ直したもの)。

サーバの生ログ POST 行(抜粋)表
method POST
host b.test:8443
path /counter
origin https://a.test:8443
sec-fetch-site cross-site
sec-fetch-mode cors
body note=hello

スクリプトは読めず、サーバは処理しました。 6回の結果をまとめます。

3エンジン × 2回(カウンタの絶対値は前の実験からの続き。差だけを見る)表
エンジン 回 前 → 後 スクリプトが受け取った例外
Chromium 1 7 → 8 TypeError: Failed to fetch
Chromium 2 8 → 9 TypeError: Failed to fetch
Firefox 1 9 → 10 TypeError: NetworkError when attempting to fetch resource.
Firefox 2 10 → 11 TypeError: NetworkError when attempting to fetch resource.
WebKit 1 11 → 12 TypeError: Load failed
WebKit 2 12 → 13 TypeError: Load failed

例外の文言はエンジンの実装で、仕様が決めているのは network error として TypeError になるところまでです。どのログにも OPTIONS は来ていません。text/plain の POST はプリフライトが挟まらない範囲の要求だからです。

ブラウザはログのとおり、どこから来たか(origin、sec-fetch-site)を要求に付けて送りました。一方、利用者がこの POST を望んだかどうかは、どこにも確かめられていません。

✓ ここまでの確認

実測でスクリプトに TypeError が返ったとき、サーバ側では何が起きていましたか。

抽象原理

何を守るかの線は、curl で引かれている

鍵が読む側にあるなら、許可を出してよいかはどう決めるのか。Fetch Standard の Background reading にある “Basic safe CORS protocol setup”(節番号なし)が、判定の物差しを書いています。

For resources where data is protected through IP authentication or a firewall (unfortunately relatively common still), using the CORS protocol is unsafe. (This is the reason why the CORS protocol had to be invented.)

Thus in other words, if a resource cannot be accessed from a random device connected to the web using curl and wget the aforementioned header is not to be included.

「the aforementioned header」は Access-Control-Allow-Origin: * です。Web 上の任意の機器から curl で取れるものなら、ブラウザ経由で読ませても新しく漏れるものは無い。 守る対象は、ブラウザがその位置にいるから取れる応答だけです。これは「CORS は curl と同じだから無意味」という話ではありません。

ここから一般化できます。CORS では許可を宣言する係(サーバの応答ヘッダ)と、実際に止める係(ブラウザ)が別で、止める対象は読み取りです。プリフライトが挟まらない要求では、宣言は本体への応答に載るので、ブラウザがそれを見るのは要求が届いた後です。宣言をどう変えても、要求が届いてサーバが処理することは止まりません。止めたければ、サーバが自分で処理の前に判断するしかありません。一方、プリフライトが挟まる要求では、プリフライトへの応答に許可が無ければ本体は送られません(§4.8 CORS-preflight fetch。CORS check が成功しなければ network error を返す)。

§3.3.5 も、資格情報付きの要求を許すかどうかをサーバの開発者の判断に置いています。

Generally speaking, both sharing responses and allowing requests with credentials is rather unsafe, and extreme care has to be taken to avoid the confused deputy problem.

具体実物

宣言する係と止める係が別、という構造は WAF にもある

第3期『WAFは何を見ているのか』で、CRS の既定方式を見ました。個々のルールは検出だけを担当し、遮断の判断はしません。 ルール 942100 は setvar でスコアを加算するだけで deny を持たず、遮断は 949110 の仕事でした。

構造が CORS と同じです。942100 が一致したことは、遮断されたことを意味しません。止めるのは 949110 です。CORS でも、サーバが許可ヘッダを返すかどうかは判定の材料を出しているだけで、止めるのはブラウザ、しかも読み取りです。

どちらも「設定したから止まる」と言うには、止める係がどこにいて、何を止めるのかを名指しする必要があります。

✓ ここまでの確認

Fetch Standard の Background reading にある “Basic safe CORS protocol setup” は、`Access-Control-Allow-Origin: *` を付けてはいけない資源をどう説明していますか。

⇄ 2つを見比べる
具体例 A

CORS の許可ヘッダ

サーバは応答ヘッダで共有の可否を宣言するだけです。止めるのはブラウザで、止める対象はスクリプトが応答を読むことです。単純な要求では、ブラウザが宣言を見る時点で、要求はもう届いています。

役割の分担表
係 やること
サーバの応答ヘッダ 共有してよいかを宣言
ブラウザ 応答をスクリプトに渡すか決める
(その手前) 要求の送信とサーバの処理
具体例 B

CRS の検出ルールと遮断ルール

ルール 942100 は一致するとスコアを加算するだけで、deny を持ちません。遮断を決めるのは合計と閾値を比べる 949110 です。検出ルールだけを見て止まったかどうかは言えません。

役割の分担(第3期より)表
係 やること
942100 一致したらスコアを加算
949052 スコアを合計値にまとめる
949110 合計が閾値以上なら deny

ブラウザの仕組みと、サーバの手前の仕組み。置き場所は違いますが、2つに共通する構造は何でしょうか。

それぞれで「止める」という動作を実際に持っているのはどの係かを探してください。

この章のまとめ

  • CORS は、応答が別オリジンと共有できるかを応答の側が宣言する仕組み(Fetch §3.3)。
  • 実測では、許可ヘッダを返さないサーバに text/plain で POST すると、3エンジン×2回すべてで届いて処理され、スクリプトには TypeError だけが返った。
  • opt-in の理由は ファイアウォールの内側の応答を漏らさないため。CSRF は理由に挙がっていない。
  • Access-Control-Allow-Origin: * の可否は curl と wget で取れるかで判定する(Basic safe CORS protocol setup)。
  • プリフライトが挟まる要求では本体の前に OPTIONS で確かめられる。「CORS は送信を止めない」と一般化せず、単純な要求に限って言う。
  • ブラウザが要求に付けるのはどこから来たかまで。処理してよいかは、サーバが自分で確かめる。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

Fetch Standard §3.3 は、CORS を opt-in(許可する側が明示する方式)にする必要がある理由として何を挙げていますか。

用語集(3語)
CORS(CORS protocol)
応答が別オリジンと共有できることを、応答の側が宣言する仕組み。Fetch Standard §3.3 が定める。読めるかどうかは応答が戻ってから CORS check(§4.10)で決まる。
Access-Control-Allow-Origin
応答をどのオリジンと共有してよいかをサーバが示す応答ヘッダ。返さなければ、ブラウザは別オリジンのスクリプトに応答を渡さない。プリフライトが挟まらない要求では、サーバの処理は止めない。
プリフライト(CORS-preflight request)
本体を送る前に OPTIONS で許可を確かめる要求。メソッドやヘッダが safelisted の範囲を出るときに挟まる。資格情報は含まない(§3.3.5)。