CORSの二十年にわたる不良債権:解説を書いている本人たちまでコメント欄で殴り合っている

CORSWebセキュリティHTTPクロスオリジン同一オリジンポリシー

データソース:HN + Lobsters · HN

深夜二時、あなたはローカルでCreate React Appを起動し、フロントエンドは3000番ポート、バックエンドAPIは8000番ポートで動作している。最初のfetch()呼び出しを書くと、Chromeのコンソールに赤い文字が表示される:「has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource」。あなたはGoogleで「CORS error fix」を検索し、最初のStack Overflowの回答が「バックエンドにAccess-Control-Allow-Origin: *を追加せよ」と教えてくれる。その通りにすると赤い文字は消え、世界は平和になる。この一行の設定が実際に何をしているのか、サーバーサイドのヘッダーがなぜブラウザ側の動作を制御できるのか——あなたは確信が持てず、深く考えたいとも思わない。なにしろコードは動いているのだ。

これは地球上で一秒ごとに起きている光景である。二十年が経過した今も、CORSはWeb開発において最も「修正されたが理解されていない」セキュリティメカニズムであり続けている。2026年6月、2019年の古い記事「Developers don’t understand CORS」がHacker Newsで353ポイント、251コメントを獲得して再浮上した。コメント欄で最も高評価の二つのコメントは真っ向から対立している。その下のサブスレッドは二百階層にわたって戦い、双方が典籍を引用し合い、最終的に誰も誰も説得できなかった。さらに示唆的なのは、批判の対象となったのがまさにこの解説記事自体だったことだ——「Even TFA (The F*ing Article) seemingly doesn’t understand CORS(そのクソ記事自体がCORSを理解していないようだ)」。解説を書いている人自身も混同しているのである。

CORSは結局何をしているのか

この口論を理解するには、最も基礎的な問いに立ち返らなければならない:CORSは何をするものなのか。筆者が過去の技術文献と法的仕様から整理した全体像はおおよそ次の通りである——CORS(Cross-Origin Resource Sharing、クロスオリジンリソース共有)はブラウザが実装するプロトコルであり、特定の条件下で同一オリジンポリシー(Same-Origin Policy、SOP)の制限を緩和するために用いられる。そう、緩和であって、強化ではない。SOPはブラウザに組み込まれたセキュリティの土台である:デフォルトでは、example.comから読み込まれたJavaScriptはbank.comにリクエストを送信し、そのレスポンスを読み取ることができない。このデフォルトポリシーはユーザーを保護する——あなたがネットバンキングにログインしており、ブラウザに認証Cookieが保存されている場合、あなたが開いた他のどのウェブサイトもあなたの銀行データをこっそり読み取ることはできない。CORSの役割は、サーバーが「特定の他のオリジンは私のレスポンスを読んでもよい」と宣言するメカニズムを提供することであり、Access-Control-Allow-Originなどのレスポンスヘッダーを通じて実現される。

命名自体がその位置づけを示唆している:それは共有(Sharing)に関するものであり、ブロックに関するものではない。しかしこの命名こそが大量の混同の源泉である——開発者がコンソールに「blocked by CORS」というエラーを見たとき、直感的な反応は「CORSが私をブロックしている」である。実際にリクエストをブロックしているのはSOPであり、CORSはあなたがクロスオリジンを試みる際にブラウザがサーバーの認可をチェックする一連のフローである。サーバーが認可していない場合、ブラウザの動作は「レスポンスを読ませない」(そして非単純リクエストの場合はそもそも送信させない)であり、この「させない」がCORSという名前のせいにされている。名前と動作のズレが、認知的負債の最初の元本である。

二種類の「正しい」が二百階層戦った

HNの議論で最も核心的な分岐は二つのコメントの対峙に凝縮できる。一つ目はユーザーmuvlon(最多投票、17時間前に投稿)のもので、大意はこうだ:この記事自体がCORSを理解していない——CORSはリクエストを阻止しない、デフォルトの制限を緩和するだけだ。任意のウェブサイトからのJavaScriptはあなたのlocalhost:19421にリクエストを送信できる。Access-Control-Allow-Originヘッダーはレスポンスが読めるかどうかを決めるだけで、リクエスト自体はいずれにせよ送信される。二つ目はユーザーstymaar(12時間前)のもので、直接反論する:いや、あなたの言うことは間違っている——GETのような安全なメソッドでは確かにリクエストは送信されるが、GETは冪等であるべきであり、レスポンスが読めないことが全ての保護である。非冪等リクエストについては、ブラウザはまずOPTIONSプリフライトを送信し、プリフライトのレスポンスに正しいCORSヘッダーがなければ、ブラウザは実際のリクエストを全く送信しない。

二人ともデタラメを言っているわけではない。彼らは各自が定義したシナリオの中で正しいのである。muvlonがカバーするシナリオは「単純リクエスト」(simple requests)——プリフライトをトリガーしない種類のものだ:GET、HEAD、POST(Content-Typeがapplication/x-www-form-urlencodedmultipart/form-dataまたはtext/plain)、および安全な標準ヘッダー群。これらのリクエストは送信され、サーバーが処理し、レスポンスも返ってくるが、ブラウザはただJavaScriptにレスポンスを渡さないだけだ。stymaarが描写するのは「非単純リクエスト」——PUT、DELETE、PATCH、Content-Typeがapplication/jsonのPOST、Authorizationヘッダーを伴うリクエストなど。これらはまずOPTIONSプリフライトをトリガーし、プリフライトが通らなければ実際のリクエストは送信されない。

工学的判断はこうだ:両派は各自の文脈で理にかなっているが、各自がその理屈を全域の真理として語った。muvlonの「the requests happen in any case(リクエストはいずれにせよ発生する)」は全域陳述としては誤りである——非単純リクエストではプリフライトの失敗が確かにリクエスト送信を阻止する。stymaarがプリフライトメカニズムで原著者を弁護する方法にも穴がある——彼はZoomのシナリオがローカルlocalhostサーバーを含み、攻撃面がGET系の単純リクエストから来ることを見落としている。原著者の「only Javascript running on the zoom.us domain can talk to the localhost webserver(zoom.usドメインで実行されているJavaScriptだけがlocalhostウェブサーバーと通信できる)」という表現は確かに不正確である:任意のウェブサイトがこのlocalhostサーバーと「talk to」(単純リクエストを送信)でき、認可されたウェブサイトだけがレスポンスを読める。もしlocalhostサーバーが危険な操作をGETエンドポイントに載せていたら、Access-Control-Allow-Originはリクエストを防げず、レスポンスが読まれるのを防ぐだけだ。そして破壊的なGETリクエストは、送信されてしまえばそれで終わりである。

プリフライトの微積分

プリフライトメカニズム自体がより多くの見落とされがちな詳細を秘めている。HNの議論で誰かが指摘したように、POSTリクエストのContent-Typeをtext/plainに設定すればプリフライトを迂回できる——なぜならtext/plainは「単純リクエスト」のホワイトリストにあるからだ。攻撃者は次のようなフォームを構築できる:

<form action="https://victim.com/api" method="POST" enctype="text/plain">
  <input name='{"key":"value", "ignore":"' value='"}'>
</form>

サーバーに送信される内容は{"key":"value", "ignore":"="}となり、異常なJSONのように見えるが、バックエンドがContent-Typeヘッダーを厳密にチェックせずに直接JSON.parseをbodyに適用すると、このリクエストはプリフライトの障壁を突破できる。複数回のペネトレーションテストでこのテクニックを成功裏に悪用したと主張するコメント投稿者が現れた。これは純粋な理論的推論ではない——サーバーがContent-Type検証を行わない限り、text/plainとmultipart/form-dataの単純POSTは任意のペイロードを運ぶことができる。同様に、Content-Typeチェックが完全一致ではなくプレフィックス一致で行われている場合、multipart/form-data; boundary=application/jsonのようなヘッダー値も迂回可能である。

これらのエッジケースが示すのは一つのことだ:CORSのセキュリティモデルは「リクエストがサーバーに到達できるか」にも「レスポンスが読めるか」にも還元できない——それは分岐木であり、単純リクエストと非単純リクエストは異なるパスを進み、異なるパス上の保護境界は同一ではない。いずれか一つのパス上のルールを全域ルールに一般化すると、認知的歪みが生まれる。そしてこの分岐木は今も新しい枝を伸ばし続けている——Sec-Fetch-*ヘッダー、SameSite cookie属性、Cross-Origin-Embedder-PolicyCross-Origin-Opener-Policy、各層がCORSの上に新しい意味論を重ね、もともと単純でなかったメンタルモデルをさらに把握困難にしている。

なぜ解説著者まで間違えるのか

Chris Fosterの元記事は2019年7月に発表され、核心事例はZoomのローカルウェブサーバー脆弱性である。Zoomはユーザーのマシン上でlocalhost:19421をリッスンするウェブサーバーを実行していた。ユーザーがZoomリンクをクリックすると、ウェブページはこのローカルサーバーにリクエストを送り、ネイティブクライアントを起動した。CORSを迂回するために、ZoomはAJAXを使わず画像を読み込み、画像のサイズを通じてステータスコードをエンコードした。Fosterの提案は:このローカルウェブサーバーはAccess-Control-Allow-Origin: https://zoom.usを設定すべきであり、そうすれば「zoom.us上のJavaScriptだけがローカルサーバーと通信できる」。

Fosterの前半の判断(Zoomのやり方は安全でない)は正しいが、後半の表現(「zoom.usだけが通信できる」)は技術的に曖昧である。厳密に言えば、Access-Control-Allow-Originは他のウェブサイトがlocalhostに単純リクエストを送信するのを防ぐことはできず、他のウェブサイトのJavaScriptがレスポンスを読み取るのを防ぐことしかできない。もしローカルウェブサーバーが機密操作をGETエンドポイントに露出させていたら、CORSヘッダーだけでは不十分である。

しかしこれはFoster一人の問題ではない。HNのコメントスレッド全体が様々な方法でCORSを論じており、論者間の食い違いは彼らとFosterの間の食い違いより小さくない。ある者はCORSは一切リクエストを阻止できないと主張し、ある者はプリフライトこそがリクエスト阻止のためだと反論し、第三者がPOST text/plainとフォームリクエストによるプリフライト迂回問題を指摘し、第四者がたとえリクエストが送信されてもCORSヘッダーなしではレスポンスが読めないのだからGET系操作の保護は完全だと補足する——サーバーが書き込み操作をGETに載せさえしなければ。反論の各層が前の層の不完全性を暴露し、最終結果は251コメントの後もなお合意が形成されていない。

筆者が観察したパターンはこうだ:CORSの認知的困難は単に複雑だからだけではなく、開発者が正しくモデル化するために三つのことを同時に理解する必要があるからでもある——ブラウザのSOP基盤、SOPの緩和メカニズムとしてのCORS、そしてHTTPメソッドの安全性と冪等性の約束事。これら三つはそれぞれブラウザアーキテクチャ、Webセキュリティプロトコル、RESTful設計という三つの領域に属し、ほとんどの開発者はそのうち一つか二つにしか精通していない。「SOP + CORS」だけでモデル化する人は「リクエストがブロックされた」という結論に至りやすい(ブラウザレベルの全体的効果がそのように見えるからだ)。「HTTP意味論」だけでモデル化する人はサーバーがリクエストを受け取りレスポンスを返したのを見て「リクエストは明らかに送信された」と見る。二つのモデル化は異なるレベルでそれぞれ正しいが、それらが同じ名詞「CORS」に投射されると衝突する。

時代の裂け目

コメント欄の一つの観察は非常に興味深い:これは世代間問題かもしれない。CORSが出現する前にWeb開発を始めたプログラマーなら、SOPだけがあり正当なクロスオリジンリクエストがなかった時代を経験している。JSONPがどのようにハックされたかを知っている。<img>タグと<script>タグがなぜクロスオリジン可能でXHRが不可能だったかを知っている。CORSが登場したとき、SOPに扉が開かれたのを見た——それは解決策だった。しかしCORSが既に存在した後にWebアプリケーションを書き始めたなら、遭遇した最初のクロスオリジンエラーには「blocked by CORS」と書かれており、直感はCORSが仕事の邪魔をしているというものだ——それは問題である。

世代間の違いは確かに存在するが、より深い問題はCORSの文書、教育、エラーメッセージが設計上既に認知的バイアスを含んでいることにある。ブラウザのコンソールエラーメッセージは「blocked by CORS」と書き、「blocked by Same-Origin Policy due to missing CORS authorization(CORS認可の欠如により同一オリジンポリシーによってブロック)」とは書かない。MDN文書は完全なメカニズムを説明しているが、ほとんどの開発者は完全な文書を読まない——問題を修正できる最初のStack Overflowの答えを検索して止まる。HNのコメント欄で複数の人が認めている:「CORS問題に遭遇するたびに一から学び直し、学び終えるとまた忘れる。」自称CTOのコメント投稿者は、自社のユーザーがCORS問題に大量に遭遇してサポートを求めてくるとし、彼の観察はこうだ:今は本当に理解する必要はない、ClaudeとGPTが既にCORSエラーを修正できるからだ——エラーをLLMに投げればいい。別の者は即座に反論した:最近遭遇したCORSエラーはClaude、Copilot、そして別のシニアエンジニアの三道の防衛線を突破して初めて解決された。解説を書く人も読む人も殴り合っているなら、LLMが混乱した訓練データから学んだ答えがどれほど信頼できるというのか。

不良債権は清算される予定がない

CORSの設計者が直面したのはほぼ不可能な任務だった:二十年のWebの歴史的遺産との互換性を保ちながら、ブラウザのクロスオリジンインタラクションに安全な認可メカニズムを提供する。HTML <form>のクロスオリジン能力はCORS出現の二十年前から存在しており、単純に封鎖すればインターネット全体が破壊される。CORSは折衷ルートを選択した:「単純リクエスト」を後方互換に保ち、「非単純リクエスト」にプリフライトを導入する。当時この選択は実用的だったが、複雑性をプロトコル本体に内在化させた——開発者はどのリクエストが単純でどれがそうでないか、どのヘッダーが安全でどれがそうでないか、OPTIONSリクエストがなぜ現れそれが実際のリクエストとどのような関係にあるかを理解しなければならない。二十年を経て、SameSiteSec-FetchCOEPCOOPなどの新たな層が重ねられ、複雑性は増すばかりである。

筆者は次のように考える傾向がある:CORSの認知的負債の根源はWebプラットフォーム自体の進化方式にある——後方互換性は硬い制約であり、段階的進化が唯一実行可能なパスである。そして各段階の妥協は、後続の開発者が追加で学習しなければならない概念的負債を残す。この負債は清算が難しい、なぜなら既にブラウザと数十億のウェブページのDNAに刻み込まれているからだ。

HNのあのコメントスレッドはおそらくCORS問題の終点にはならないだろう。しかしそれは良い断面図である——技術コミュニティの中で最もこのトピックに関心を持つ人々が集まり二日間激しく議論しても、最も基本的な事実について合意できないことを示している。もしこのグループでさえ統一できないなら、一般の開発者がCORSの細部を精確に掌握することを期待するのは、おそらく現実的ではない。


本文はChris Fosterの原文およびHacker Newsの議論スレッドの技術的分析に基づいている。筆者はCORS仕様の原著者でもブラウザエンジン開発者でもなく、文中の技術メカニズムの解釈は公開標準文書とコミュニティ議論の読解から来ており、偏差を含む可能性がある。本文中の技術的記述に誤りを発見した場合は、WHATWG Fetch StandardおよびMDN Web Docsを基準とされたい。