コンテンツへスキップ

CORS(オリジン間リソース共有)の基礎 — ブラウザがなぜAPIリクエストをブロックするのか

Webサイトを跨いだAPI連携を実装していると、ブラウザのコンソールに赤字で has been blocked by CORS policy と表示されてリクエストが失敗する場面に一度は遭遇する。サーバー側は正常に応答しているのに、ブラウザだけがそれを握りつぶしてくる。今回は、この挙動の背景にある「同一オリジンポリシー」と、それを緩和する仕組みである CORS(Cross-Origin Resource Sharing)が何を守っているのかを整理する。

そもそも「オリジン」とは何か

補足: オリジン(origin)とは、URLの「スキーム(https://)+ホスト名(wpmm.jp)+ポート番号」の組み合わせのこと。パス部分(/blog/ 等)は含まない。https://wpmm.jphttps://en.wpmm.jp はホスト名が違うので別オリジン、https://wpmm.jphttp://wpmm.jp はスキームが違うのでこれも別オリジンになる。

ブラウザには「同一オリジンポリシー(Same-Origin Policy)」という基本ルールがあり、あるオリジンから読み込まれたJavaScriptは、原則として別オリジンに対する読み取り系のレスポンスにアクセスできない。これはブラウザに内蔵された制約であり、サーバー側の設定ではない。

なぜこの制約が必要なのか

このポリシーがないとどうなるかを考えると理由が分かりやすい。もし同一オリジンポリシーが存在しなければ、悪意あるサイトを開いただけで、そのページ内のJavaScriptが裏で銀行サイトへリクエストを送り、ブラウザに保存された認証Cookieを使ってレスポンス(口座残高等)を読み取ってしまう、という攻撃が成立してしまう。ブラウザはリクエストを送ること自体は止めないが、別オリジンからの応答をJavaScript側に読み取らせないことで、この種の情報窃取を防いでいる。

CORSは「制約を安全に緩める」ための仕組み

とはいえ、正当な理由で別オリジンのAPIを呼びたいケースは非常に多い。CORSは、サーバー側が「このオリジンからの読み取りは許可する」と明示的に宣言するための仕組みだ。サーバーがレスポンスに Access-Control-Allow-Origin: https://example.com というヘッダーを付けて返すと、ブラウザはそのオリジンからのJavaScriptに対してだけレスポンスの読み取りを許可する。

ポイントは、この判断をしているのはサーバーではなくブラウザ側だということ。サーバーはヘッダーを返しているだけで、実際にレスポンスを読み取らせるかどうかの最終判断はブラウザが行う。同じレスポンスでも、Postmanやcurlのようなブラウザを介さないツールからは常に中身が見えるが、ブラウザ経由のJavaScriptから見た場合だけCORSの制約が働く。

プリフライトリクエスト(preflight)

単純なGETリクエストなら、ブラウザは実リクエストを送ってからレスポンスヘッダーを見てブロック判断をする。しかし Content-Type: application/json を付けたPOSTのように「単純ではない」リクエストの場合、ブラウザは本番のリクエストを送る前に OPTIONS メソッドで事前確認リクエスト(プリフライト)を送る。サーバーが Access-Control-Allow-MethodsAccess-Control-Allow-Headers を含むレスポンスを返して初めて、ブラウザは本番のリクエストを送信する。

ワイルドカード * と個別オリジン指定の使い分け

Access-Control-Allow-Origin には、任意のオリジンを許可する *(ワイルドカード)と、特定のオリジンだけを許可する文字列指定の2通りがある。使い分けの基準は「そのAPIがCookie等の認証情報を使うかどうか」だ。

  • 認証情報(Cookie・Authorization ヘッダー等)を使わない、誰が呼んでも同じ結果を返す公開APIなら、* で十分
  • Cookieセッションに紐づく個人情報を返すAPIでは、* は使えない仕様になっている(ブラウザ側の仕様上、Access-Control-Allow-Credentials: true* は併用できない)。呼び出し元を許可リストで絞り込み、リクエストごとの Origin ヘッダーと照合して個別に許可を返す必要がある

自社実装での使い分け

このアプリのLPが持つ2つのAPIエンドポイントが、この使い分けをそのまま体現している。

チャットボットAPI(server/wpmm-web/api/chat.php)は、許可オリジンの配列と Origin ヘッダーを突き合わせ、一致した場合だけそのオリジンを返す方式を取っている。

$allowed_origins = [
    'https://wpmm.jp',
    'https://www.wpmm.jp',
    'https://en.wpmm.jp',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
}

セッション単位でレート制限や会話ログを扱うAPIのため、任意のサイトから叩けてしまうと連投・悪用の入口になる。オリジンを自社LPに限定することで、埋め込み元を管理下に置いている。あわせて Vary: Origin ヘッダーを付けているのも意図的な設計で、CDNやブラウザのキャッシュ層に対して「このレスポンスはOriginヘッダーの値によって内容が変わる」と伝え、あるオリジン向けに許可したレスポンスが別オリジンからのリクエストに誤ってキャッシュ配信されないようにしている。

一方、決済セッション作成API(server/wpmm-web/api/checkout.php)は Access-Control-Allow-Origin: * を返している。こちらはStripeの決済セッションを作成して外部リダイレクト先URLを返すだけの、Cookie等の個人情報に紐づかない処理であり、レスポンス自体に呼び出し元固有の情報が含まれないため、オリジンを絞る必要性が薄いという判断だ。

なお、LPトップに表示している「最新の記事」セクション(includes/blog_latest.php)はさらに別の解決策を取っている。あれはブラウザのJavaScriptがブログのREST APIを直接呼んでいるのではなく、PHP(サーバー)側がサーバー間通信でREST APIを取得してHTMLとして描画している。ブラウザを経由しないサーバー間の通信には、そもそも同一オリジンポリシーもCORSも適用されない。「ブラウザからの直接呼び出しを許可リストで制御する」方式と、「そもそもブラウザを経由させない」方式は、同じ課題に対する別解として捉えられる。

まとめ

CORSは、同一オリジンポリシーというブラウザの安全装置を、サーバー側が明示的な許可を出すことで安全に緩める仕組みだ。判断の主体はブラウザであり、サーバーは「許可する・しない」の意思表示をヘッダーで返しているに過ぎない。ワイルドカードで広く許可するか、許可リストで個別に絞るかは、そのAPIが認証情報に紐づく応答を返すかどうかで決まる。そして、そもそもブラウザ経由のリクエストにしなければCORSという概念自体が登場しない、という設計上の逃げ道があることも覚えておくと選択肢が広がる。