Yuki's Tech Blog

仕事で得た知見や勉強した技術を書きます。

Same-origin policyとCORSについてざっくりまとめる

目次

概要

雰囲気でCORSを知っていたので、ちゃんと理解しようと思います。 CORSを知る前に知る必要がある概念については、事前にまとめておきます。

Web開発における「リソース」とは

RFC3986にリソースについて書いてあったので、見てみましょう。

datatracker.ietf.org

リソース

本仕様書では、リソースとなりうるものの範囲を限定しない。 むしろ、"リソース "という用語は、URIによって識別されるかもしれないものすべて リソース "という用語は、URIによって識別されるかもしれないあらゆるものに対して一般的な意味で使用される。 よく知られている例 身近な例としては、電子文書、画像、一貫した目的を持つ情報源(例えば 例えば、電子文書、画像、一貫した目的を持つ情報源(例えば、"Today's weather report for Los Angeles の今日の天気予報 "など)、サービス(HTTP-to-SMSゲートウェイなど)、そ 他のリソースの集まりである。 リソースは必ずしも 例えば、人間、企業、図書館にある製本された本などである。 例えば、人間、企業、図書館の本もリソースになりうる。 同様に 抽象的な概念もリソースになり得ます。 同様に、抽象的な概念もリソースとなりうる。 (例えば、数学の方程式の演算子オペランド、関係のタイプ(例えば、「親」や「従業員」)、数値(例えば、ゼロ、1、無限大)などである、 1、無限大)。

この説明を読んで、リソースとは、URLで特定できるWeb上で利用可能な情報やデータであることが分かりました。

オリジンとは

RFC6454にオリジンについて書いてあったので、見てみましょう。

triple-underscore.github.io

3.2. 生成元( origin )

原理的には、 UA がどの URI も別々な保護ドメインとして扱って,ある URI から検索取得された内容と 別の URI とのやりとりに,明示的な同意を要するようにすることもできる。 あいにく、 web アプリケーションは,協同して動作するいくつものリソースからなることが多いので、この設計は開発者には厄介なものになる。 代案として,UA は、 URI たちを, “生成元” と呼ばれる保護ドメインにひとまとめにする。 大雑把に言えば、 2 つの URI は,それらが同じ[スキーム, ホスト, ポート]を持つならば,同じ生成元に属する(すなわち,同じ主体を表現する)。 (全部的な詳細は § 4 を見よ。)

UA(この場合だとブラウザ)がどのURIも保護すべきものとして扱ってしまうと、あるURIから取得されたリソースから別のURIでリソースをリクエストする際に、明示的な同意を要する必要があります。これは複数のリソースからなることが多いWebアプリケーションにとっては厄介なものです。

そのため、UAは、URIたちをオリジンと呼ばれる保護ドメインにまとめました (保護ドメインとは、コンピュータシステムやソフトウェアにおいて、特定のセキュリティポリシーを持ち、そのポリシーに基づいてアクセス制御やリソースの保護を行う単位である)。

2つのURIは、同じ「スキーム、ホスト、ポート」を持つならば、同じオリジンに属します。

つまり、オリジンとは、リソースの生成元を表す保護ドメインであることが分かります。 オリジンは、「スキーム(プロトコル)、ホスト(ドメイン)、ポート」によって構成されています。

あるホスト上のアプリケーションが別のホストのアプリケーションとtcp通信をする際に、ipアドレスとポート番号が必要なので、オリジンの概念もそれと近しいものだなと個人的に思いました(オリジンの場合はプロトコルも関係している)。

オリジンへのネットワークアクセスについて

RFC6454にオリジンへのネットワークアクセスについて書いているので、見てみましょう。

triple-underscore.github.io

3.4.2. ネットワークアクセス

ネットワークリソースへのアクセスの可否は、[ そのリソースは、アクセスを試みている内容と同じ生成元に属しているかどうか ]に依存する。 一般に、別の生成元から情報を読み取ることは禁止される。 しかしながら,一部の種類のリソースを利用することは、他の生成元から検索取得する場合でも許可される。 例えば生成元には、どの生成元からの[ スクリプトを実行する/画像を描画する/スタイルシートを適用する ]ことも許可されている 【この文書が書かれた頃までは — 現在はもっと制約されている】 。 同様に生成元は、 HTML のフレーム内の HTML 文書など,別の生成元からの内容を表示できる。 ネットワークリソースには、他の生成元に自身の情報を読み取らせるオプトインを備えるものもある — 例えば Cross-Origin Resource Sharing [CORS] を利用して。 これらの事例でアクセスが是認されるかどうかは、概して生成元ごとに基づく。

これを見る限り、あるオリジンに所属するリソースが同じオリジンに所属するリソースに対してHTTPリクエストを出すのは問題ないことが分かります。そして、あるオリジンに所属するリソースが別のオリジンに所属するリソースに対してHTTPリクエストを出すのは禁止されているということが分かります。

Same-origin policyとは

先ほどの説明を読む限り、オリジンという概念が導入されたおかげで、全てのURIを保護すべきものだと考る必要がなくなり、オリジン単位でURIを保護することが可能になりました。そして、あるオリジンに所属するリソースから同じオリジンのリソースに対してリクエストを出すのはOKだけど、あるオリジンに所属するリソースから、異なるオリジンのリソースに対してリクエストを出すのはNGであること分かりました。 もし実際に異なるオリジンのリソースに対してリクエストを出した場合、ブラウザがSame-origin policyによってリクエストを自動的に禁止してくれます。

Same-origin policyとは、異なるオリジンのリソースに対してのリクエストをブラウザ側で制限することです。

別オリジンのリソースへのアクセスだけどSame-origin policyの制約がないものについて

あるオリジンのリソースから別のオリジンのリソースにアクセスする際に、Same-origin policyの制約がないものが存在します。

  • Same-origin policyの制約があるもの

    • ブラウザ上でJavaScriptを用いて実行する非同期HTTPリクエス
  • Same-origin policyの制約がないもの

    • formタグ
    • scriptタグ
    • img タグ
    • linkタグ

これらの要素(form, script, img, link)を使う際には、CSRFの対策ができているかをよく確認しましょう。 RailsのformにおけるCSRF対策では、CSRFトークンが隠しフィールドで入っています。

zenn.dev

CORSとは

Same-origin policyはブラウザのセキュリティを向上させます。しかし、AjaxやSPA構成のアプリケーションができなくなる等、他の問題を生みます(Next.jsとRailsがそれぞれ別オリジンの場合、今のままだとNext.jsからRailsにHTTPリクエストを出せない)。そこでSame-origin policyの制約を完全に無効にするわけではなくて、Same-origin policyの制約を緩和するためにCORSが導入されました。

CORSとは、相手側オリジンの許可を得ることで、相手側オリジンのリソースに対してのHTTPリクエストを可能にするプロトコルです。

CORSプロトコルは、HTTPレスポンスをクロスオリジンで共有できるかどうかを示す一連のヘッダーで構成されます。このHTTPヘッダーのおかげで、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにある選択されたリソースへのアクセス権を与えるようブラウザーに指示できます。

CORSを利用する際のざっくりとした手順

CORSには、「シンプルリクエスト」と「プリフライトリクエストを伴うリクエスト」の2パターンがあるのですが、ここではシンプルリクエストが実行される際の手順について説明します。

ブラウザはあるオリジンのリソースから異なるオリジンのリソースに対してHTTPリクエストを出す際に、HTTPリクエストメッセージにOriginヘッダを自動で追加してくれます(同じオリジンのリソースをリクエストする際には、ブラウザはOriginヘッダをリクエストに追加しない)。このOriginヘッダにはリクエスト元のオリジンが指定されています。リクエストを受けたサーバーは、レスポンスをする際にレスポンスメッセージにCORSに関するヘッダー(Access-Control-Allow-Origin)を含めていない場合、ブラウザはオリジンをまたいだリソースの利用に失敗します。

オリジンを跨いでリソースを使用したいなら、オリジンのリソースから異なるオリジンのリソースに対してリクエストが来た際に、サーバー側でHTTPレスポンスにCORSに関するヘッダー(Access-Control-Allow-Origin)を追加すれば良いです。これでCORSに関する設定は終了です。そうすれば、異なるオリジンのリソースにアクセスできます。

フレームワークを使っていれば、CORSに関するライブラリが使えるので、CORSに関するヘッダーを自分で設定する必要はなく、おそらく設定ファイルを使ってCORSに関する設定をすれば良いだけでしょう。

別オリジンにHTTPリクエストをする際の2つのリクエストについて

あるオリジンのリソースから異なるオリジンのリソースに対してHTTPリクエストを行う場合、実はそのリクエストは、ブラウザによって「シンプルリクエスト」 または「プリフライリクエストを伴うリクエスト」として解釈されて実行されています。

シンプルリクエストとは

シンプルリクエストは、以下の条件を満たすようなリクエストです。 これらの条件は、HTMLフォームから送られるリクエストを基準としています。

メソッドは下記のうちいずれかである

  - GET
  - HEAD
  - POST

ユーザーエージェント(Webの場合はブラウザ)によって自動的に設定されたヘッダー (たとえば Connection、 User-Agent、 または Fetch 仕様書で禁止ヘッダー名として定義されているヘッダー)を除いて、手動で設定できるヘッダーは、 Fetch 仕様書で CORS セーフリストリクエストヘッダーとして定義されている以下のヘッダーだけである

- Accept
- Accept-Language
- Content-Language
- Content-Type(以下しか指定できない)
  - application/x-www-form-urlencoded
  - multipart/form-data
  - text/plain
  - Range

その他

- リクエストに ReadableStream オブジェクトが使用されていないこと。

もし、ブラウザからシンプルリクエストが送信された場合、サーバーからのレスポンスにAccess-Control-Allow-Originヘッダーが付与されていて、かつそのヘッダーのvalueでリクエスト元のオリジンが許可されていれば、レスポンスのリソースを使用することができます。

レスポンスで付与するAccess-Control-Allow-Originヘッダで Origin リクエストヘッダのリテラル値 (nullとすることもできる) または* を返すことで、レスポンスをそのオリジンと共有できることをブラウザに伝えることができます。

プリフライトリクエストとは

プリフライトリクエストとは、サーバーがCORSプロトコルを理解して準備されていることをチェックするためのリクエスです。異なるオリジンにリクエストを出す際に、そのリクエストが「シンプルリクエストの条件」を満たさない場合に、プリフライトリクエストと呼ばれるHTTPリクエスト(リクエストメソッドはOPTIONS)が事前に送信されます。プリフライトリクエストは、ブラウザが自動的に発行するものです。シンプルリクエストができる場合は、プリフライトリクエストは発生しません。

プリフライトリクエストのレスポンスヘッダーを見て、サーバーにリクエストしても良いことが分かったら、ブラウザは元々のリクエストを別オリジンに対して自動的に送信します。そして、レスポンスを利用できます。

どうでも良いですが、preflightは「飛行前に起こる」という意味の形容詞です。つまり、プリフライトリクエストとは、「実際のリクエストが起きる前に起こるリクエスト」という意味であることが分かります。

eow.alc.co.jp

Same-origin policyやCORSが存在する理由

もし、オリジンの制約がなく非同期リクエストが可能だと、罠サイトを作ってCSRFでやりたい放題できてしまいます(ユーザーのアカウントで全く別のサイトにリクエストを出したり機密情報を盗みまくれる)。 そのため、Same-origin policyを導入することによって、別オリジンからの非同期リクエストを制限しました。 しかし、Same-origin-policyだけだと制限が厳しくAjaxやSPAなどもできなくなるので、あるオリジンからのリクエストなら特別に許すというCORSプロトコルが導入されました。

補足) CSRFとは

CSRFは、サイト自体が攻撃者です。 CSRFとは、罠サイトから、あなたがよくつかうような全く別の他のサイトへのリクエストを開始し、意図しない「退会」や「決済」や「コメント」なんかを実行させるといった類の攻撃です。 jsにおける非同期リクエストでなくても、formやaタグでCSRF攻撃はできます。

CSRFの対処策としては、CSRFトークンをリクエスト時に必ず持たせるようにして、サーバー側ではクッキーの値とCSRFトークンの値が一致するかをチェックすれば良いです(CSRFトークンはバックエンドアプリで生成しておいて、リクエスト時にカスタムヘッダーに仕込んでおく)。

別オリジンのリソースからこのオリジンに対してリクエストされる時に、そのリクエストのどれかはCSRFの可能性があるので(バックエンド側は誰からリクエストが来たのかに関心がない)、認めたオリジンからのリクエストなのかをCSRFトークンまたはCORSでチェックする必要があります。

ここまでのSame-origin policyとCORSについてのまとめ

  • リソースとは、URLで特定できるWeb上で利用可能な情報やデータである。
  • オリジンとは、リソースの生成元を表す保護ドメインである。
    • あるオリジンに所属するリソースが同じオリジンに所属するリソースに対してHTTPリクエストを出すのは問題ないことが分かります。そして、あるオリジンに所属するリソースが別のオリジンに所属するリソースに対してHTTPリクエストを出すのは禁止されています。
  • Same-origin policyとは、異なるオリジンのリソースに対してのリクエストをブラウザ側で制限することである。
  • CORSとは、相手側オリジンの許可を得ることで、相手側オリジンのリソースに対してのHTTPリクエストを可能にするプロトコルである。
    • 具体的には、サーバーからのレスポンスにAccess-Control-Allow-OriginヘッダーというCORSに関するヘッダーを付与すれば、異なるオリジンのリソースを別のオリジンで利用できる
    • 異なるオリジンへアクセスをする際には、シンプルリクエストまたは、プリフライトリクエストの2パターンのリクエストのうちどちらかが送信されている。

Same-origin policyを体験してみる

以下のリポジトリを元に進めます。

github.com

サーバー側はhttp://localhost:3030をオリジンとします。 フロントエンド側はhttp://localhost:8080をオリジンとします。

http://localhost:8080から配信された以下のようなhtmlから、jsで別オリジン(http://localhost:3030)のリソース(http://localhost:3030/api/users/search)に対してGETリクエストを出してみます。

<html>
<body>
  <h1>ユーザー検索</h1>
  <!-- methodをgetにしちゃうと、ボディがクエリパラメータになっちゃうね。サーチくらいならまだ良いか。-->
  <!-- 自作サーバーにクエリストリングの処理を入れてないから、postにした。サーチは本来ならgetが良い。-->
  <button id="button">検索</button>
  <script>
    document.getElementById("button").addEventListener("click",() => {
      const request = new XMLHttpRequest();
      // onreadystatechangeプロパティは、XMLHttpRequestオブジェクトの状態が変化するたびに実行される関数を指定します:
      request.onreadystatechange = () => {
        // readyStateプロパティが4で、statusプロパティが200のとき、応答はreadyである:
        if (request.readyState == 4 && request.status == 200) {
          alert(request.responseText);
        }
      }
      request.open("GET", "http://localhost:3030/api/users/search", true);
      request.send();
    })
  </script>
</body>
</html>

検索ボタンを押して、jsからHTTPリクエストを発火させようとしたら、Same-Origin-Policyによるエラーが出ました。

オリジン 'http://localhost:8080' から 'http://localhost:3030/api/users/search' の XMLHttpRequest へのアクセスは、CORS ポリシーによってブロックされました: 要求されたリソースに 'Access-Control-Allow-Origin' ヘッダーがありません。

要は、http://localhost:8080のオリジンのリソースから、http://localhost:3030のオリジンのリソースにアクセスしようとしたから、オリジン違くねとブラウザがこのエラーを出しただけです。

jsリクエストのリクエストヘッダーを見ると、ちゃんとOriginヘッダーを送っているのが確認できます。Originヘッダは異なるオリジンにリクエストを出す際に自動でブラウザがつけてくれるヘッダです。 Originヘッダには、リクエスト元のオリジンが指定されていることが分かります。

これらの検証から、Same-origin policyが実際に機能していることが確認できました。

CORSを体験してみる

先ほどSame-Origin-Policyの制約が実際に機能していることを確認できました。 今度はCORSを利用して、Same-Origin-Policyの制約を緩和しようと思います (上でも書きましたが、CORSはSame-Origin-Policyを完全に無効にするわけではなくて、Same-Origin-Policyの制約を緩和するだけです)。

シンプルリクエス

上のSame-origin policyの制約に引っかかったリクエストは、よく見てみるとシンプルリクエストの条件を満たすので、シンプルリクエストであることが分かります。

そしてブラウザが出したエラー文を見てみます。

オリジン 'http://localhost:8080' から 'http://localhost:3030/api/users/search' の XMLHttpRequest へのアクセスは、CORS ポリシーによってブロックされました: 要求されたリソースに 'Access-Control-Allow-Origin' ヘッダーがありません。

このエラー文を読んでみると、このシンプルリクエストが失敗している原因は、シンプルリクエストをした際のサーバーからのレスポンスメッセージのヘッダーに、Access-Control-Allow-Originヘッダーがないためです。

つまり、Access-Control-Allow-Originヘッダーをレスポンスに付与して、異なるオリジンがこのリソースを利用することを許可すれば良いということです(Same-origin policyの制約を緩和している)。

http:localhost:3030のサーバー側で、レスポンスに以下のヘッダーを付与しました。

   // コルスの設定
    response.SetHeader("Access-Control-Allow-Origin", "http://localhost:8080")

このヘッダーをつけることで、Same-origin policyの制約があるけど、このオリジン(http://localhost:8080)からのHTTPリクエストは許すよということをブラウザに伝えることができます。

変更後、検索ボタンを押すと、リクエストヘッダーにOrigin。レスポンスヘッダにAccess-Control-Allow-Originヘッダがあることが確認できました。

jsからのリクエストで取得した別オリジンのリソースもちゃんと利用できました。 (alertで利用できている)

Image from Gyazo

プリフライトリクエス

プリフライトリクエストのリクエストメソッドはOPTIONSです。 OPTIONSはHTTPメソッドの一つです。指定されたURLまたはサーバーの許可されている通信オプションをリクエストする際に使うメソッドです。

developer.mozilla.org

プリフライトリクエストを発生させるには、シンプルリクエストの条件を満たさないリクエストを送信すれば良いです。シンプルなやり方だと、Content-Type: application/jsonでリクエストすれば良いです。そうすると、シンプルリクエストの条件を満たさないので、リクエストの前にプリフライトリクエストをブラウザが送信してくれます。先ほどのhtmlを変更します。

<html>
<body>
  <h1>ユーザー検索(プリフライトリクエスト)</h1>
  <!-- methodをgetにしちゃうと、ボディがクエリパラメータになっちゃうね。サーチくらいならまだ良いか。-->
  <!-- 自作サーバーにクエリストリングの処理を入れてないから、postにした。サーチは本来ならgetが良い。-->
  <button id="button">検索</button>
  <script>
    document.getElementById("button").addEventListener("click",() => {
      const request = new XMLHttpRequest();
      // onreadystatechangeプロパティは、XMLHttpRequestオブジェクトの状態が変化するたびに実行される関数を指定します:
      request.onreadystatechange = () => {
        // readyStateプロパティが4で、statusプロパティが200のとき、応答はreadyである:
        if (request.readyState == 4 && request.status == 200) {
          alert(request.responseText);
        }
      }
      request.open("GET", "http://localhost:3030/api/users/search-after-preflight", true);
      request.setRequestHeader("Content-Type", "application/json")
      request.send();
    })
  </script>
</body>
</html>

プリフライトリクエストの詳細

プリフライトリクエストの詳細を見てみると、jsでリクエストしようとしているリソースに対して、OPTIONSメソッドでリクエストが実行されていることが分かりました。このプリフライトリクエストの実行結果でCORSに関するヘッダーを返していないと、プリフライトリクエストは成功しても、jsのリクエストが失敗します。

プリフライトリクエスト後のリクエストでエラーが発生

コンソールに出ているエラーの内容を見てみます。

Access to XMLHttpRequest at 'http://localhost:3030/api/users/search-after-preflight' from origin 'http://localhost:8080' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

日本語に訳してみます。

オリジン 'http://localhost:8080' から 'http://localhost:3030/api/users/search-after-preflight'XMLHttpRequest へのアクセスは、CORS ポリシーによってブロックされました: リクエストヘッダーフィールド content-type は、プリフライト応答の Access-Control-Allow-Headers によって許可されていません

プリフライトリクエストとは、サーバーがCORSプロトコルを理解して準備されていることをチェックするためのリクエストです。 つまり、「jsでリクエストする際のリクエストメッセージに含まれているcontent-Type: application/jsonをサーバーが許可しているかどうか」を、プリフライトリクエストのレスポンスメッセージのヘッダーに含めていないので、このエラーが出ています。

プリフライトリクエストをした際にどんなHTTPメッセージがサーバーに送信されているのか見てみましょう。

OPTIONS /api/users/search-after-preflight HTTP/1.1
# Host リクエストヘッダーは、リクエストが送信される先のサーバーのホスト名とポート番号を指定します。
Host: localhost:3030
Connection: keep-alive
# HTTP の Accept リクエストヘッダーは、クライアントが理解できるコンテンツタイプを MIME タイプで伝えます。 
Accept: */*
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
Origin: http://localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
Sec-Fetch-Dest: empty
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6,zh;q=0.5

プリフライトリクエストのhttpリクエストメッセージではこの3つのヘッダが特徴的です。

# 同じリソースに対する今後の CORSリクエストに利用され得る 【リクエスト側が希望する】 メソッドを指示する。
Access-Control-Request-Method: GET
# 同じリソースに対する今後の CORS リクエストに利用され得る 【リクエスト側が希望する】 ヘッダを指示する。
Access-Control-Request-Headers: content-type
Origin: http://localhost:8080

このヘッダのプリフライトリクエストに対してレスポンスでは以下のヘッダを応答する必要があります。

Access-Control-Allow-Methods
Access-Control-Allow-Headers
Access-Control-Allow-Origin

プリフライトリクエストのレスポンスを生成する際に以下のヘッダーを追加したら、プリフライトリクエスト後のリクエストが成功しました。 (下でAccess-Control-Allow-Originも追加している)

       response.SetHeader("Access-Control-Allow-Methods", "GET")
        response.SetHeader("Access-Control-Allow-Headers", "Content-Type")

以下の画像はプリフライトリクエストのレスポンスです。レスポンスヘッダーを見ると、サーバーはこのリソース(http://localhost:3030/api/users/search-after-preflight)に対してGETメソッドとContent-Typeの使用を許可していることが分かります。その情報をブラウザに伝えることができたので、ブラウザはjsによるhttpリクエストをちゃんと送信できました。

プリフライトリクエストのレスポンス

プリフライトリクエストのレスポンスが帰ってきて、次のリクエストをしている

jsからのリクエストで取得した別オリジンのリソースもちゃんと利用できました。 (alertで利用できている)

Image from Gyazo

認証情報を含めてリクエストしてみる

デフォルトでは、クロスオリジンに対するリクエストには、HTTP認証やクッキーなどの認証に用いられるリクエストヘッダ(Cookieヘッダ)は自動的に送信されません。これが送信されたら、CSRFでログイン権限のあるリクエストをやりたい放題できてしまいます。

今回は、js側とサーバー側に設定をして、異なるオリジンに対してクッキーを送信できるようにします(CookieはhttpリクエストメッセージのCookieヘッダによって送れます)。

まずはセッションカウンタのページを作ってみます。 count upボタンを押すとサーバーにリクエストをします。サーバー側では受け取ったクッキーの値からカウントを増やして、その値をjsonでフロントエンドに送信します。フロントエンドではそのjsonをパースして画面に反映します。

localhost:8080のhtml

<html>
<body>
  <h1>セッションカウンタ</h1>
  <span id="counter"></span>
  <!-- methodをgetにしちゃうと、ボディがクエリパラメータになっちゃうね。サーチくらいならまだ良いか。-->
  <!-- 自作サーバーにクエリストリングの処理を入れてないから、postにした。サーチは本来ならgetが良い。-->
  <button id="button">count up</button>
  <script>
    document.getElementById("button").addEventListener("click",() => {
      const request = new XMLHttpRequest();
      // onreadystatechangeプロパティは、XMLHttpRequestオブジェクトの状態が変化するたびに実行される関数を指定します:
      request.onreadystatechange = () => {
        // readyStateプロパティが4で、statusプロパティが200のとき、応答はreadyである:
        if (request.readyState == 4 && request.status == 200) {
          const span = document.getElementById("counter")
          span.textContent = request.responseText
        }
      }
      request.open("GET", "http://localhost:3030/api/authentication-included-request", true);
      request.send();
    })
  </script>
</body>
</html>

localhost:3030のサーバーサイド(goで実装してます)

func (c *AuthenticationIncludedRequest) Action(request *http.Request) *http.Response {
    cookieHeaders := map[string]string{}
    cookie, isThere := request.Cookies["counter"]
    var response *http.Response

    if isThere {
        currentCount, _ := strconv.Atoi(cookie.Value)
        currentCount += 1
        cookieHeaders["counter"] = fmt.Sprintf("%v", currentCount)

        response = http.NewResponse(
            http.VersionsFor11,
            http.StatusSuccessCode,
            http.StatusReasonOk,
            request.TargetPath,
            []byte{byte(currentCount)},
        )
    } else {
        currentCount := 1
        cookieHeaders["counter"] = fmt.Sprintf("%v", currentCount)

        response = http.NewResponse(
            http.VersionsFor11,
            http.StatusSuccessCode,
            http.StatusReasonOk,
            request.TargetPath,
            []byte{byte(currentCount)},
        )
    }

    // クッキーの設定
    for key, value := range cookieHeaders {
        response.SetCookieHeader(fmt.Sprintf("%s=%s", key, value))
    }

    // コルスの設定
    response.SetHeader("Access-Control-Allow-Origin", "http://localhost:8080")

    return response
}

count up ボタンを押して、シンプルリクエストが送信されたことは確認できました(別オリジンへのリクエストに成功している)。 CORSリクエストのレスポンスでSet-Cookieヘッダが入っているのは確認できました。しかし、別オリジンにはデフォルトでクッキーが送信されないので、そのせいで2回目にボタンを押した時のリクエストで、ブラウザはクッキーを勝手に送信してくれません(つまり、リクエスト時にCookieヘッダがない)。カウンターはずっと1のままです。

これを解決するには、XMLHttpRequestのwithCredentialsプロパティにtrueをセットする必要があります。このプロパティをセットすることで、リクエストでクッキーが送信されるようになります(リクエストにCookieヘッダが付与されるようになる)。

      request.open("GET", "http://localhost:3030/api/authentication-included-request", true);
      request.withCredentials = true;
      request.send();

しかし、実際にリクエストをすると、リクエストが失敗していることが確認できます。

エラー文を読んでみます。

authentication-included-request:1 Access to XMLHttpRequest at 'http://localhost:3030/api/authentication-included-request' from origin 'http://localhost:8080' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

日本語に訳してみます。

authentication-included-request:1 オリジン「http://localhost:8080」から「http://localhost:3030/api/authentication-included-request」のXMLHttpRequestへのアクセスは、CORSポリシーによってブロックされた: レスポンスの 'Access-Control-Allow-Credentials' ヘッダーの値は '' です。リクエストの資格情報モードが 'include' の場合は 'true' でなければなりません。XMLHttpRequest によって開始されるリクエストのクレデンシャルモードは withCredentials 属性によって制御される。

つまり、withCredentials = trueにしたリクエストに対しては、レスポンスでAccess-Control-Allow-Credentials: trueというレスポンスヘッダを返す必要があるということが分かります。

実際にレスポンスヘッダを追加してみます。

   response.SetHeader("Access-Control-Allow-Credentials", "true")

レスポンスにちゃんとAccess-Control-Allow-Credentialsヘッダが含まれていることが確認できます。

これで異なるオリジンでもCookieを送信することができるようになりました。

ここまでのまとめ

  • あるオリジンのリソースから別のオリジンのリソースに対してシンプルリクエストを送信したい場合、別のオリジンのレスポンスにAccess-Control-Allow-Originヘッダを追加する必要がある。
  • あるオリジンのリソースから別のオリジンのリソースに対してプリフライトリクエストを送信する場合、プリフライトリクエストのレスポンスでは、Access-Control-Allow-Methodsヘッダ、Access-Control-Allow-Headersヘッダ、Access-Control-Allow-Originヘッダの3つを追加する必要がある(この3つのヘッダをプリフライトリクエストのレスポンスに追加すると、プリフライトリクエスト後のリクエストが失敗しなくなる)
  • あるオリジンのリソースから別のオリジンのリソースに対してクッキーを伴うリクエストを送信したい場合、js側ではwithCredentialsの設定、サーバー側ではレスポンスにAccess-Control-Allow-Credentialsヘッダを追加する必要がある。
  • フレームワークを使っている場合、上の設定を手動でやらずに、CORSの設定ファイルを書けばうまくいくかも。

終わり

CORSってなんかよく分かんないなと思っていたのですが、実際に手を動かしてみることで、よりCORSについて理解することができました。近年のSPAアプリケーションにおいてCORSの理解はあった方が良いので、今のうちに理解を深めることができて良かったなと思いました。

参考記事

Rails API + SPAのCSRF対策例

ミルクボーイがCORSを説明しました

Fetch Standard

Fetch Standard (日本語訳)

オリジン間リソース共有 (CORS) - HTTP | MDN

CORS, 同一オリジンポリシーについてまとめる

https://www.amazon.co.jp/dp/4797393165