0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【やさしく】CORSについて理解する

0
Posted at

はじめに

こんばんは、mirukyです。

ブラウザからAPIを呼んだら、コンソールに赤い英文が出る。なのにAPIのログを確かめると、ちゃんと 200 OK で返している。CORSのエラーは、この食い違いがほどけると、あとは芋づる式に理解できます。

今回は、手元に小さなカフェの注文アプリを立てて、わざとCORSエラーを出しながら、名前の由来から回避の方法まで確かめていきます。いきましょう!

目次

  1. CORSの正式名称と呼び方
  2. オリジンはURLのどこまでか
  3. 同一オリジンポリシーと、CORSが生まれた理由
  4. 検証に使うカフェの注文アプリ
  5. CORSエラーは、届いた応答をブラウザが渡さない状態
  6. Access-Control-Allow-Originを1行足すと受け取れる
  7. JSONのPOSTでは、先にプリフライトが飛ぶ
  8. Cookie付きのリクエストで増える2つの条件
  9. サブドメインが違うと、オリジンは別でもサイトは同じ
  10. CORSを回避する実例

1. CORSの正式名称と呼び方

CORSはCross-Origin Resource Sharingの頭文字です。オリジン(Origin)をまたいで(Cross)、リソース(Resource)を共有する(Sharing)ので、日本語ではオリジン間リソース共有と訳されます。この記事の訳語はMDN(Firefoxを作っているMozillaが運営する、HTMLやJavaScript、HTTPの解説サイト)の日本語版に合わせています。

CORSがCross-Origin Resource Sharingの頭文字で、日本語ではオリジン間リソース共有になることを、4つの英単語と訳で示した図

呼び方は仕様にもMDNにも決まりがなく、コルス、シーオーアールエス、コースなど人によって違います。この記事ではコルスと呼びます。やっていることは、別のオリジンから取ってきたデータをページのJavaScriptに渡してよいかを、サーバーがHTTPのヘッダーでブラウザに伝えることです。まずは、このオリジンが何を指すのかから確かめます。

2. オリジンはURLのどこまでか

オリジンはURLのうちスキーム(http や https)、ホスト(ドメイン名)、ポートの3つを組にしたものです。4章で使う注文画面のURLでいえば、http://app.cafe.localhost:5173 までがオリジンで、後ろのパスの /api/menu やクエリは判定の外です。

http://app.cafe.localhost:5173/api/menu のうち、スキーム、ホスト、ポートの3つがオリジンで、パスは含まないことを示した図

3つのうち1つでも違えば、別のオリジンです。ポートを書かないURLは、httpなら80、httpsなら443として扱われるので、http://example.com と http://example.com:80 は同じオリジンになります。実際の判定を、注文画面のURLを基準に並べてみます。

http://app.cafe.localhost:5173 を基準に、パスだけ違うURLは同じオリジン、スキーム、ポート、ホストのどれかが違うURLは別のオリジンになることを並べた図

とくに注意したいのが、ホストの違いです。app.cafe.localhost と api.cafe.localhost のように、先頭のサブドメイン(ドメイン名の前に付け足した部分)だけが違う2つも別のオリジンですし、api.cafe.localhost とその親の cafe.localhost も別のオリジンです。4章のアプリではAPIを api.cafe.localhost に置くので、ページとAPIは最初から別のオリジン同士になります。

3. 同一オリジンポリシーと、CORSが生まれた理由

CORSより先に、同一オリジンポリシー(Same-Origin Policy、SOP)というブラウザの決まりがあります。あるオリジンのページで動くスクリプトが、別のオリジンから取ってきた応答の中身を取り出すことを、原則として許さない決まりです。

なぜ必要かはネットバンキングの場面で考えると腑に落ちるので、bank.example にログインしたまま、別のタブでたまたま evil.example を開いたとします。ブラウザには bank.example のログインのCookieが保存されているので、evil.example のスクリプトが bank.example へ問い合わせると、そのリクエストにはCookieが付きます。その応答をスクリプトがそのまま受け取れたら、残高は evil.example に筒抜けです。

bank.exampleにログインしたまま開いたevil.exampleのスクリプトが、Cookie付きでbank.exampleへ問い合わせても、同一オリジンポリシーが応答をJavaScriptへ渡さないことを示した図

同一オリジンポリシーが止めるのは、主に応答の中身を取り出すほうです。リンクをたどる、フォームを送信する、別のオリジンの画像やスクリプトをページに埋め込む、といった使い方は基本的に許されていて、別のオリジンから届いた応答の中身をスクリプトで取り出すことが禁じられています。リクエストそのものはサーバーまで届く、という点は5章の画面で確かめます。

同一オリジンポリシーは、1995年のNetscape Navigator 2.02から入っている古い決まりです。そのあと、ページのJavaScriptから別のドメインのAPIを呼ぶ作りが当たり前になり、どのオリジンになら応答を渡してよいかを、サーバーからブラウザへ伝える方法が必要になりました。そうして決められたのがCORSで、最初はW3C(Webの標準を定める団体)の仕様として、いまはWHATWG(主要なブラウザの開発元が運営する標準化団体)がまとめるFetch Standardの一部として定義されています。

1995年に同一オリジンポリシーが入り、2014年にCORSがW3C勧告になり、2020年にFetch Standardへ移った流れを示した年表

CORSのエラーという名前なので、CORSが通信を止めているように見えますが、止めているのは同一オリジンポリシーのほうで、CORSはそこに例外を作る仕組みです。ここを逆に覚えると、エラーを消そうとしてブラウザの設定を変えにいく遠回りになります。

4. 検証に使うカフェの注文アプリ

ここからは、手元で動かしたアプリで確かめます。画面を配るフロント(フロントエンド)は http://app.cafe.localhost:5173 で動くVite 6.3.5(フロントエンドの開発用ツール)の開発サーバー、APIは http://api.cafe.localhost:8080 で動くPython 3.10の標準ライブラリだけのサーバーです。Chromeは *.localhost の名前を手元のマシン(127.0.0.1)に解決するので、hostsファイル(ホスト名とIPアドレスの対応を書く設定ファイル)を書き換えずに、サブドメインを2つ用意できます。ブラウザはChrome 157で、開発者ツールは日本語の表示です。

APIはCORSのヘッダーを環境変数で付けたり外したりできる作りにしていて、ヘッダーを付ける部分は次の十数行です。

import os

ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "")
CREDENTIALS = os.environ.get("CORS_CREDENTIALS") == "1"


def cors_headers(self):
    # ブラウザに、このオリジンなら応答を渡してよいと伝えるヘッダー
    if ALLOW_ORIGIN:
        self.send_header("Access-Control-Allow-Origin", ALLOW_ORIGIN)
        self.send_header("Vary", "Origin")
    # Cookie付きのリクエストにも応答を渡してよいと伝える(8章)
    if CREDENTIALS:
        self.send_header("Access-Control-Allow-Credentials", "true")

最初はCORSのヘッダーを1つも付けない状態で2つのサーバーを起動します。

# CORSのヘッダーを付けない状態で、APIを api.cafe.localhost:8080 で起動します
python3 api.py

# 別の端末で、フロントを app.cafe.localhost:5173 で起動します(中身は vite --host 127.0.0.1 --port 5173)
npm run dev

http://app.cafe.localhost:5173 を開くと、左に5つのボタン、右に結果の欄が並ぶ画面になります。5章から10章では、このボタンを上から押していきます。

カフェの注文アプリの画面。左に①から⑤のボタン、右に結果の欄がある

①はメニューのGET、②はJSONでの注文のPOST、③はCookie付きのGET、④は10章のプロキシ経由、⑤は mode: 'no-cors' を付けたGETです。ボタンの中身は fetch を1回呼んで、応答のステータス、応答の種類を表す type、本文を右の欄に出すだけです。

const API = 'http://api.cafe.localhost:8080'

// ① 別のオリジンにあるAPIから、メニューを取ってくる
document.getElementById('menu').onclick = () =>
  show('GET ' + API + '/api/menu', () => fetch(`${API}/api/menu`))

// ② JSONで注文を送る(Content-Type が application/json になる)
document.getElementById('order').onclick = () =>
  show('POST ' + API + '/api/orders', () => fetch(`${API}/api/orders`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ item: 'カフェラテ', qty: 1 }),
  }))

// ③ Cookie を付けて、自分の注文を問い合わせる
document.getElementById('me').onclick = () =>
  show('GET ' + API + '/api/me', () => fetch(`${API}/api/me`, { credentials: 'include' }))

5. CORSエラーは、届いた応答をブラウザが渡さない状態

まずはヘッダーなしのAPIに対して、①でメニューを取りにいきます。右の欄には TypeError: Failed to fetch とだけ出て、コンソールにはCORSのエラーが2行出ました。

①を押すと結果の欄にTypeError: Failed to fetchが出て、コンソールにCORS policyのエラーと、GETのnet::ERR_FAILED 200 (OK)が出た画面

エラーの理由は1行目の最後にある No 'Access-Control-Allow-Origin' header is present on the requested resource です。要求したリソースの応答に Access-Control-Allow-Origin のヘッダーがない、と言っています。2行目をよく確かめると、net::ERR_FAILED の後ろに 200 (OK) とあり、APIは正常に答えています。失敗の赤字の中に成功の200が混ざっているので、最初は混乱しますよね。

ネットワークのタブで、このリクエストを開いてみます。ステータスコードは 200 OK で、レスポンスヘッダーには Content-Length や Content-Type が並ぶ一方で、Access-Control-Allow-Origin の行が抜けています。

ネットワークタブでmenuを開いた画面。ステータスコードは200 OKで、レスポンスヘッダーにAccess-Control-Allow-Originがない

応答はブラウザまで届いているのに、レスポンスのタブに切り替えると、本文の代わりに取得に失敗したという表示が出ます。CORSの確認に通らなかった応答は、ページのJavaScriptだけでなく、この画面でも中身が空のままです。

レスポンスのタブで、本文の代わりに応答データの取得に失敗したという表示が出た画面

では、ブラウザは何を手がかりに判定しているのでしょうか。同じリクエストのリクエストヘッダーを下へ進めると、fetch のコードでは書いていない Origin: http://app.cafe.localhost:5173 が付いています。

リクエストヘッダーに、Origin: http://app.cafe.localhost:5173 と、Sec-Fetch-Mode: cors、Sec-Fetch-Site: same-site が付いている画面

Origin はブラウザが自動で付けるヘッダーで、どのオリジンのページからのリクエストかをサーバーに伝えます。その下の Sec-Fetch-Mode: cors と合わせると、このリクエストはCORSの確認を受ける前提で送られたとわかります。もう1行の Sec-Fetch-Site: same-site が何を表すかは、9章の話です。

APIのログにも、リクエストはしっかり届いていました。

GET /api/menu  Origin: http://app.cafe.localhost:5173  -> 200

ここまでをまとめると、APIは処理を終えて200で返していて、止めているのはその先のブラウザです。応答に Access-Control-Allow-Origin がないので、ブラウザがJavaScriptへの受け渡しを打ち切っています。

ページのJavaScriptがfetchを頼み、ブラウザがOriginを付けてAPIへ送り、APIが200とデータを返したが、Access-Control-Allow-Originがないのでブラウザが渡さない流れ

この動きからわかるとおり、CORSはブラウザの中だけで働く仕組みです。curl やサーバー同士の通信はCORSの確認を通らずにそのまま届きますし、ブラウザからのGETもAPIまで届いて処理されます。守りたいデータは、CORSとは別に、ログインの確認(認証)と権限の確認(認可)で守ります。

6. Access-Control-Allow-Originを1行足すと受け取れる

APIが応答に Access-Control-Allow-Origin を付ければ解決します。まずは、どのオリジンにも渡してよいという意味の * を付けて起動し直します。

# どのオリジンのページにも、応答を渡してよいと伝えます
CORS_ALLOW_ORIGIN='*' python3 api.py

同じく①を押すと、今度は status: 200、type: cors で、メニューの3品が結果の欄に出ました。type の cors はCORSの確認に通った別オリジンの応答という意味です。

①を押すと、status: 200 type: corsで、ブレンド、カフェラテ、チーズケーキのメニューが出た画面

ネットワークのタブでは、レスポンスヘッダーの先頭に Access-Control-Allow-Origin: * が増えています。変えたのはAPIの応答ヘッダー1行だけで、フロントのコードはそのままです。

レスポンスヘッダーに Access-Control-Allow-Origin: * が付いている画面

公開しているメニューのように、誰に渡してもよいデータなら * で十分です。渡す先を絞るなら * の代わりに http://app.cafe.localhost:5173 のような具体的なオリジンを書きますが、書けるのは1つだけです。複数のオリジンを許すときは、サーバーがリクエストの Origin を許可リストと照らし合わせて、合っていたらその値をそのまま返します。返す値がリクエストごとに変わるので、Vary: Origin も付けて、ブラウザや配信サーバーが保存した応答のコピー(キャッシュ)を、別のオリジン向けに使い回さないようにします。

7. JSONのPOSTでは、先にプリフライトが飛ぶ

次は②でカフェラテを注文します。APIは Access-Control-Allow-Origin: http://app.cafe.localhost:5173 を返す設定に変えましたが、OPTIONS(GETやPOSTと同じHTTPのメソッドの1つで、サーバーに送り方を尋ねるときに使う)のリクエストにはまだ答えない状態です。

# 応答を渡してよいオリジンを、フロントの1つに絞ります(OPTIONSには405を返す)
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' python3 api.py

②を押すと、また TypeError: Failed to fetch です。ただし、エラーの文が5章と違っていて、Response to preflight request doesn't pass access control check、つまりプリフライトのリクエストへの応答が確認に通らなかった、と言っています。

②を押すとコンソールに、Response to preflight request doesn't pass access control checkのエラーとPOSTのnet::ERR_FAILEDが出た画面

ネットワークの一覧を確かめると、orders が2行あります。下の行はタイプが preflight でステータスが405、上の行はタイプが fetch でステータスが CORS エラーです。

ネットワークの一覧に、ordersのpreflightが405、ordersのfetchがCORS エラーで並んだ画面

APIのログには、OPTIONS が1行あるだけで、POST は届いていませんでした。5章のGETとは違い、今回は注文の本体がAPIへ届く前に止まっています。

OPTIONS /api/orders  Origin: http://app.cafe.localhost:5173  -> 405

ここが、CORSの中でいちばんややこしいところですかね。この OPTIONS がプリフライト(preflight、事前の確認)です。ブラウザは別のオリジンへ送るリクエストのうち、決まった条件を満たさないものについて、本番の前に OPTIONS で、このメソッドとこのヘッダーで送ってもよいか、をサーバーに尋ねます。プリフライトなしで送れる条件は3つあり、条件を満たすリクエストは、古い仕様の言葉を借りて単純リクエストと呼ばれています。

メソッド、自分で付けるヘッダー、Content-Typeの値の3つの条件をすべて満たすとプリフライトなしで送られることと、それぞれの許される値を並べた図

②の注文は Content-Type: application/json なので、3つ目の条件から外れます。この3つは、HTMLのフォームで昔から送れたリクエストの範囲と重なる条件です。フォームで送れるリクエストはもともとどのオリジンへも送れたので、サーバー側はすでに対策しているはず、という前提で、確認を省いています。JSONのPOSTや、Authorization ヘッダーを付けたリクエストは、フォームでは送れなかった種類なので、先にサーバーの同意を取ります。

APIに、OPTIONS への答え方を足します。

def do_OPTIONS(self):
    # プリフライトへの答え。どのメソッドとヘッダーを使ってよいかを返す
    self.send_response(204)
    self.cors_headers()
    self.send_header("Access-Control-Allow-Methods", "GET, POST")
    self.send_header("Access-Control-Allow-Headers", "Content-Type")
    # この答えを600秒(10分)キャッシュしてよい、と伝える
    self.send_header("Access-Control-Max-Age", "600")
    self.end_headers()
# OPTIONSに204で答える設定で起動し直します
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' CORS_PREFLIGHT=1 python3 api.py

もう一度②を押すと、status: 201、type: cors で注文が通りました。ネットワークの一覧では、preflight が204、fetch が201になっています。

②を押すとstatus: 201 type: corsで注文が受け付けられ、ネットワークの一覧でpreflightが204、fetchが201になった画面

ブラウザとAPIの間では、2往復のやりとりが起きています。1往復目がプリフライトで、許可が出たので2往復目で本番のPOSTを送り、201の応答を受け取っています。

ブラウザがまずOPTIONSで尋ね、APIが204で許可を返し、そのあと本番のPOSTを送って201を受け取る2往復の流れ

プリフライトの行を開くと、リクエストメソッドは OPTIONS、ステータスコードは 204 No Content です。レスポンスヘッダーには使ってよいヘッダーの Content-Type、メソッドの GET, POST、オリジン、そして Access-Control-Max-Age: 600 が並んでいます。

preflightの行を開くと、OPTIONSで204 No Contentになり、Allow-Headers、Allow-Methods、Allow-Origin、Max-Age: 600が返っている画面

Access-Control-Max-Age は、プリフライトの結果を何秒キャッシュしてよいかの指定です。付けないと既定値の5秒になり、Chromium系のブラウザ(ChromeやEdge)は、大きな値を指定しても最大2時間(7200秒)までしかキャッシュしません。キャッシュが残っている間は、同じ注文を何回送っても、本番のPOSTだけが飛びます。

本番のPOSTの行も開いておきます。リクエストメソッドは POST、ステータスコードは 201 Created で、この応答にも Access-Control-Allow-Origin が付いています。

本番のPOSTの行を開くと、POSTで201 Createdになり、Access-Control-Allow-Originが付いている画面

プリフライトに許可を返しても、本番の応答に Access-Control-Allow-Origin がなければ、5章と同じエラーになります。プリフライトの応答と本番の応答の両方に付ける、と覚えておくと設定漏れを防げます。

8. Cookie付きのリクエストで増える2つの条件

ログインしているユーザーの情報を返すAPIでは、リクエストにCookieを付けたくなります。fetch は別のオリジンへのリクエストに既定でCookieを付けないので、credentials: 'include' を指定します。

まずはCookieを用意します。APIのドメインで http://api.cafe.localhost:8080/login を直接開くと、APIが会員番号のCookie(member=1024)を発行します。

api.cafe.localhost:8080/login を開くと {"login": "ok"} が返り、ネットワークの一覧にloginの200が出た画面

このCookieはAPIのドメインに保存され、③の credentials: 'include' でAPIへ問い合わせたときに、ブラウザが自動で付けて送ります。

APIを Access-Control-Allow-Origin: http://app.cafe.localhost:5173 だけ返す設定にして③を押すと、エラーになりました。

③を押すと、Access-Control-Allow-Credentialsの値が''で、'true'でなければならない、というCORSのエラーが出た画面

エラーの文には、Access-Control-Allow-Credentials の値が '' で、credentials が 'include' のときは 'true' でなければならない、とあります。Cookie付きの応答を渡すには、サーバーが Access-Control-Allow-Credentials: true も返して、同意を示す必要があります。

それならと、Access-Control-Allow-Origin を * に戻して、Access-Control-Allow-Credentials: true を付けてみます。これも、別の理由でエラーです。

# * と Allow-Credentials: true の組み合わせ(ブラウザはこれを受け付けません)
CORS_ALLOW_ORIGIN='*' CORS_PREFLIGHT=1 CORS_CREDENTIALS=1 python3 api.py

Access-Control-Allow-Originがすべてを表す*であってはならない、というCORSのエラーが出た画面

今度は、credentials が 'include' のときは Access-Control-Allow-Origin をすべてを表す * にしてはいけない、と言われます。Cookie付きの応答を渡してもらうには、フロントのオリジンを名指しで許可したうえで、Allow-Credentials も返す必要があります。

Cookie付きのリクエストで、Allow-Credentialsなしはエラー、Allow-Originが*でもエラー、特定のオリジンとAllow-Credentials: trueなら成功、の3つの場合を並べた図

オリジンを名指しして Access-Control-Allow-Credentials: true も返す設定で、起動し直します。

# オリジンを名指しして、Cookie付きの応答も渡してよいと伝えます
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' CORS_PREFLIGHT=1 CORS_CREDENTIALS=1 python3 api.py

③を押すと、status: 200 で、APIに届いたCookieの member=1024 が結果に返ってきました。

③を押すと、status: 200 type: corsで、cookieがmember=1024の結果が出た画面

レスポンスヘッダーには Access-Control-Allow-Credentials: true と、名指しの Access-Control-Allow-Origin が並んでいます。この2つが両方あって、はじめてCookie付きの応答がJavaScriptに渡ります。

レスポンスヘッダーに、Access-Control-Allow-Credentials: trueとAccess-Control-Allow-Origin: http://app.cafe.localhost:5173が付いている画面

リクエストヘッダーのほうでは、Cookie: member=1024 と、Origin: http://app.cafe.localhost:5173 が送られています。

リクエストヘッダーに、Cookie: member=1024 と Origin: http://app.cafe.localhost:5173 が付いている画面

ちなみに、失敗した2回もAPIのログには200で残っていて、止めたのはここでもブラウザです。6章で触れた、Origin を許可リストと照らし合わせる処理を省いて、届いた Origin をそのまま返す実装にすると、Cookie付きの応答をどのサイトにも渡すことになります。3章の bank.example の場面がそのまま起きるので、Allow-Credentials を付けるAPIでは、許可リストとの照合を必ず入れます。

9. サブドメインが違うと、オリジンは別でもサイトは同じ

ドメインまわりは似た名前が続くので、1つずつ確かめていきます。api.example.co.jp というドメイン名なら、先頭の api. がサブドメイン、末尾の .co.jp が公開サフィックス(MDNの日本語版では公開接尾辞)です。公開サフィックスは誰でもその下に名前を登録できる部分のことで、co.jp や com のような値が Public Suffix List という一覧で管理されています。公開サフィックスの1つ手前までを含めた example.co.jp が、登録したドメインになります(eTLD+1 とも書きます)。

この登録したドメインにスキームを加えたものを、サイトと呼びます。オリジンより大きなまとまりで、サブドメインが違うだけの2つは、オリジンは別でも、サイトは同じです。

api.example.co.jpのうち、apiがサブドメイン、co.jpが公開サフィックス、example.co.jpがサイトになることと、オリジンとサイトの判定の違いを並べた図

5章の画像で Sec-Fetch-Site: same-site が付いていたのは、app.cafe.localhost と api.cafe.localhost が、オリジンは別でも同じサイトと判定されたからです。CORSの判定はオリジンの単位なので、サブドメインが違えばCORSの設定が要ります。一方で、Cookieの SameSite 属性(そのCookieを、別のサイトから来たリクエストにも付けるかを決める設定)は、サイトの単位で判定されます。

8章のCookieは SameSite=Lax で発行していますが、同じサイトの中のリクエストなので、③の fetch にも付いて送られました。フロントとAPIを別々のドメインに置くと、オリジンもサイトも別になり、SameSite=Lax のCookieは fetch に付かなくなります。CORSのヘッダーが正しくてもCookieが届かない場合は、このサイトの判定を確かめてみてください。

10. CORSを回避する実例

CORSのエラーが出たときに取れる手は、大きく5つです。使えるのは上の3つで、下の2つはエラーが消えたように見えるだけです。

CORSを回避したいときの5つの手として、API側で許可のヘッダーを返す、開発中は開発サーバーのプロキシ、本番は同じオリジンにまとめる、mode: 'no-cors'、ブラウザのセキュリティを切る、を並べ、上の3つは使える、下の2つは使えないと示した図

API側で許可のヘッダーを返す方法は、6章から8章で試したとおりです。自分たちで管理しているAPIなら、まずこれを選びます。

開発中だけなら、開発サーバーに取り次いでもらう手もあります。Viteの開発サーバーには、特定のパスへのリクエストを別のサーバーへ取り次ぐ server.proxy という設定があります。

import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    allowedHosts: ['app.cafe.localhost'],
    proxy: {
      // ブラウザは同じオリジンの /api へ送り、開発サーバーがAPIへ取り次ぐ
      '/api': { target: 'http://api.cafe.localhost:8080', changeOrigin: true },
    },
  },
})

APIはCORSのヘッダーを付けない最初の設定に戻し、④で、同じオリジンの /api/menu を fetch します。結果は status: 200、type: basic でメニューが取れました。type の basic は同じオリジンの応答という意味です。

④を押すと、GET /api/menu(同じオリジン)がstatus: 200 type: basicでメニューを返した画面

ネットワークのタブを開くと、リクエストURLは http://app.cafe.localhost:5173/api/menu で、ブラウザから見た送り先はフロントと同じオリジンです。レスポンスヘッダーは Content-Type などの通常の行だけで、CORSのヘッダーは1つも付いていません。

リクエストURLがhttp://app.cafe.localhost:5173/api/menuで、レスポンスヘッダーにAccess-Control-Allow-Originがない画面

APIのログには、Origin のないGETが残っていました。ブラウザから見れば、送り先はずっと同じオリジンなので、CORSの確認そのものが省かれます。開発サーバーからAPIへの通信はサーバー同士のやりとりで、同一オリジンポリシーの外側にあります。

GET /api/menu  Origin: -  -> 200

ブラウザが同じオリジンの/api/menuへ送り、Viteの開発サーバーがAPIへ取り次ぎ、CORSのヘッダーなしの応答をそのままブラウザが受け取る流れ

ただし、この取り次ぎは開発サーバーの機能なので、ビルドして配信する本番では消えます。本番で同じ形にするなら、リバースプロキシ(受けたリクエストを裏のサーバーへ取り次ぐサーバー)やCDN(コンテンツを配信するサーバーの網)で、ページと同じドメインの /api 以下をAPIのサーバーへ振り向けます。たとえばAWSのCloudFrontなら、パスのパターンが /api/* のリクエストだけをAPIのサーバーへ送るビヘイビア(パスごとに送り先を決める設定)を足す形です。ページとAPIが同じオリジンになるので、CORSの設定そのものが要らなくなります。

mode: 'no-cors' は、名前だけ見るとCORSを無効にできそうです。⑤を押すと、エラーは出ない代わりに、status: 0、type: opaque が返り、本文は空でした。

⑤を押すと、mode: 'no-cors'のGETがstatus: 0 type: opaqueになり、本文が空の画面

opaque は不透明という意味で、ステータスも本文も中身を見せない応答です。ところがネットワークのタブでレスポンスを開くと、メニューの3品がしっかり届いています。

ネットワークのタブのレスポンスには、ブレンド、カフェラテ、チーズケーキのJSONが表示されている画面

応答は届いているのにJavaScriptには渡らない、という5章と同じ状態のまま、エラーの表示だけが消えています。このモードは画像の表示のように中身を取り出さない用途のためのものです。

ブラウザのセキュリティを切る方法は、起動オプションや拡張機能でブラウザの判定を止めるものです。自分のブラウザでエラーが消えても、利用者のブラウザでは同じエラーが出るので、問題はそのまま残ります。3章で確かめたとおり、同一オリジンポリシーは利用者を守るための決まりなので、サーバーから許可を伝えるか、同じオリジンにまとめる方向で考えます。

おわりに

ここまでお付き合いいただきありがとうございます。

CORSのエラーは、APIが答えた応答をブラウザがJavaScriptに渡していない状態で、直す場所はサーバーの応答ヘッダーでした。次に赤い英文を見かけたら、まず1行目の最後で、どのヘッダーが足りないと言われているかを確かめてみてください。

ではまた、お会いしましょう。

参考リンク

仕様と歴史

CORSと同一オリジンポリシー

ドメインとサイト

開発サーバー

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?