🐕‍🦺

CORSを絶対に理解する

2022/07/12に公開
1

対象者

「CORSがなんなのかわからない」
「とりあえず調べた記事でテキトーに解決したけど、根本的に何がダメだったのかがわからない」

みたいな方を対象に、CORSについて細かく解説しています。
記事を読み終えれば、CORSについて理解でき、どんな記述が必要なのかがわかるようになると思います。

また、フロントエンドしか触らない方も読んでいただけると、フロント側のエラーかと思っていたけれど実はバックエンドの方の設定が漏れていた...みたいなケースにも対応できるようになると思います。

今回サンプルコードを置いていますが、フロントはJavaScript、バックエンドはGolang(Echo)で記述しています。

まずはCORSとは何か

CORSはオリジン間リソース共有という意味です。

追加のHTTPヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンに選択されたリソースへのアクセス権を与えるようブラウザーに指示するための仕組み

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

そもそもオリジンとは?

https://developer.mozilla.org/ja/docs/Glossary/Origin

オリジンとは、ウェブコンテンツにアクセスするために使われるURLの スキーム(プロトコル)、ホスト(ドメイン)、ポート によって定義される。

http://example.com:8080/sample1/index.htmlをスキーム、ホスト、ポートに分けると以下のようになります。
スキーム: http
ホスト: example.com
ポート: :8080

例)同一オリジン
http://example.com:8080/sample1/index.html
http://example.com:8080/sample2/index.html
上記はパスが異なるだけで、スキーム、ホスト、ポートは同じであるので同一オリジンであると言えます。

例)異なるオリジン
スキームが違うため、同一オリジンではありません。
http://example.com/sample1/index.html
https://example.com/sample2/index.html

ホストが違うため、同一オリジンではありません。
http://example.com/sample1/index.html
http://www.example.com/sample1/index.html

同一オリジンポリシー

次になぜCORSがあるのかを理解するために同一オリジンポリシーについて説明します。
https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy

同一オリジンポリシーは重要なセキュリティーの仕組みであり、あるオリジンによって読み込まれた文書やスクリプトが、他のオリジンにあるリソースにアクセスできる方法を制限するもの。

同一オリジンポリシーがあることによって、悪意のあるドキュメントを隔離し、起こりうる攻撃を減らすことを目的としています。
この仕組みによって、Fech APIなどでリソースをリクエストする場合には、同じオリジンへのリソースへのリクエストしか行うことができなくなります。

同一オリジンポリシーがない場合(Bad Case)

悪意のあるサイトのオリジンを http://akui.com とし、
欲しい情報があるサイトのオリジンを https://jouhou.com とします。

  1. 送られてきたメールの中に悪意のある人が作成したWebサイトへのリンクがあった
  2. ユーザはリンク先のWebサイトへ遷移
  3. 遷移先のブラウザ上で悪意のあるJSファイルがダウンロード・実行される
  4. JSが実行されると攻撃対象(攻撃者が欲しい情報があるサイト)へのGETリクエストが送られる
  5. 情報を取得したのち、攻撃対象の情報が悪意のあるWebサイトへ返される
  6. 悪意のある人へ個人情報などが漏洩する

同一オリジンポリシーがある場合(Good)

CORSがなかった状態のブラウザ(過去のブラウザ)の流れ
悪意のあるサイトのオリジンを http://akui.com とし、
欲しい情報があるサイトのオリジンを https://jouhou.com とします。

  1. 送られてきたメールの中に悪意のある人が作成したWebサイトへのリンクがあった
  2. 遷移先のブラウザ上で悪意のあるJSファイルがダウンロード・実行される
  3. JSが実行されると攻撃対象(攻撃者が欲しい情報があるサイト)へのGETリクエストが送られる
  4. 同一オリジンポリシーにより、リクエストがブロックされる

このように、同一オリジンポリシーがあることによって情報漏洩は避けることはできるようになりました。
しかし、AJAXの普及・発展により、異なるオリジン(主に異なるホスト)のAPIを呼び出したいという動機が生まれました。

この問題を解決するために、CORS(オリジン間リソース共有)が考案されました。

CORSの基本特性について

  1. 提供する機能
  • クロスオリジン(異なるオリジン)のリソースアクセスを提供
  • オリジン単位のアクセス制御を提供
  1. 後方互換性
  • 従来動作しているサイトがCORSの元でも動作する
  • 従来の(CORS)を使わないサイトの性能劣化はほとんどない
  • 従来セキュリティ上問題のないサイトがCORSの元でも安全に動作する
  1. HTTPヘッダを用いたアクセス制御
  • リクエストヘッダ: Origin

  • レスポンスヘッダ: Access-Controll-Allow-Origin

  • 単純リクエストについては無条件でリクエストを送信して、レスポンスヘッダによりJSがリソースを受け取れるか否かを判断する

  • 単純ではないリクエストについてはプリフライトリクエストによりリクエスト送信の許可を得る

基本特性については上記の通りです。

ウェブアプリケーションは、自分とは異なるオリジンにあるリソースをリクエストするとき、
オリジン間HTTPリクエストを実行します。

オリジン間リクエストとは、例えばhttps://example.com/で提供されているウェブアプリケーションのJSコードが、fetchなどを利用してhttps://example-resouce.com/data.jsonへリクエストを行うことを指します。

CORS対応ブラウザの挙動

CORSは追加のHTTPヘッダーを使用してクライアントとサーバのやりとりを行います。

つまり、リクエストヘッダには

  • Origin
  • Access-Controll-Request-http://example.com

の2つのHTTPヘッダを追加する必要があります。

またレスポンスヘッダには

  • Access-Controll-Allow-http://example.com

のようなHTTPヘッダを追加してあげる必要があるということです。

API側でCORS許可がない場合

APIを使用する側のサイトオリジンをhttp://example.comとし、
APIを提供するサーバを持つサイトオリジンをhttp://api.example.comとします。

  1. クライアントは、APIを使用するサイトへのリクエストを送り、リソースを取得するためのJSファイルをダウンロードする
  2. JSファイルはリソースを取得するため、APIを提供するサーバへのオリジンに対しHTTPリクエストを行う
  3. HTTPリクエストには、追加のHTTPヘッダとしてOrigin: http://example.comを付与する
  4. リクエストを受けたAPIサーバは、JSONなどのリソースをHTTPレスポンスとしてリクエスト元へ返却する
  5. しかしHTTPレスポンスには、Access-Controll-Allow-http://example.comが付与されていない
  6. この場合、CORSは許可されていないこととなり、リクエスト元はリソースを取得することができない

API側でCORS許可がある場合

APIを使用する側のサイトオリジンをhttp://example.comとし、
APIを提供するサーバを持つサイトオリジンをhttp://api.example.comとします。

  1. クライアントは、APIを使用するサイトへのリクエストを送り、リソースを取得するためのJSファイルをダウンロードする
  2. JSファイルはリソースを取得するため、APIを提供するサーバへのオリジンに対しHTTPリクエストを行う
  3. HTTPリクエストには、追加のHTTPヘッダとしてOrigin: http://example.comを付与する
  4. リクエストを受けたAPIサーバは、JSONなどのリソースをHTTPレスポンスとしてリクエスト元へ返却する
  5. その際、HTTPレスポンスに追加のHTTPヘッダとしてAccess-Controll-Allow-http://example.comを付与する
  6. リクエスト元のオリジンhttp://example.comのCORSが許可されたため、リクエスト元はリソースを受け取ることができる

このようにして、同一オリジン間ポリシーを守りながら、CORSは異なるオリジン間でのリソースの共有を実現しているわけです。

ここまでの説明を実際のブラウザで確認してみる

ローカル環境で確認できるようにコードを書いてみました。
https://github.com/shouyamamoto/study/tree/main/cors/simple-cors

フロント側はlocalhost:8080
バックエンド側はlocalhost:8081でサーバーを起動させます。
そして、フロントからはlocalhost:8081/usersへリソースを取得するためにfetchを使います。

今回の場合にはポート番号が異なっていることから、異なるオリジンであると言える。そのため、同一オリジンポリシーが働くため、CORSの設定が必要です。

今回はフロントはJavaScript、バックエンドはGolang(Echo)でサンプル作成しています。

フロント

fetchでlocalhost:8081/usersに対してリクエストを送っています。
そして取得したリソースを使って、DOM操作をしています。

index.js
button = document.getElementById("getUser")
users = document.getElementById("users")

async function getUsers() {
    const res = await fetch("http://localhost:8081/users", { mode: "cors" })
    const data = await res.json()

    data.forEach(u => {
        item = document.createElement("li")
        item.innerText = `${u.name}${u.age}歳です。`
        users.appendChild(item)
    })
}

button.addEventListener("click", getUsers)

バックエンド

GolangのフレームワークであるEchoを使ってmiddleware内でCORSの設定をしていきます。

arrowOriginsで許可するオリジンを配列で指定します。今回の場合はリクエスト元のオリジンがhttp://localhost:8080のみなので以下のように記述しています。
また、middleware.CORSWithConfigでCORSの詳細な設定をできるのですが、ここで先ほど設定したオリジンを割り当てています。
つまり、http://localhost:8080からのオリジン間リクエストを許可するというコードになっています。

main.go
func main() {
	e := echo.New()
	e.HideBanner = true

	arrowOrigins := []string{"http://localhost:8080"}
	e.Use(middleware.Logger())
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: arrowOrigins,
	}))

	e.GET("/users", GetUsers)

	e.Logger.Fatal(e.Start(":8081"))
}

動作確認

  1. フロント側にユーザ取得ボタンを配置

  1. users取得時のリクエストヘッダを確認
    Origin: http://localhost:8080が確認できる。

  1. users取得時のレスポンスヘッダを確認
    Access-Controll-Allow-Origin:http://localhost:8080が確認できる。

  1. 取得したデータを埋め込むことができた

プリフライトリクエストとは(単純ではないリクエスト)

これまで単純なリクエストの場合のCORSをみてきました。
次に 単純ではないリクエスト の場合はどうなるかを説明していきます。

プリフライトリクエストは以下のように定義されています。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#preflighted_requests

「プリフライト」リクエストは始めに OPTIONS メソッドによる HTTP リクエストを他のドメインにあるリソースに向けて送り、実際のリクエストを送信しても安全かどうかを確かめます。サイト間リクエストがユーザーデータに影響を与える可能性があるような場合に、このようにプリフライトを行います。

プリフライトリクエストを理解する前に、単純リクエストについて知る必要があります。

単純リクエストとは

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#単純リクエスト

  1. 許可されているメソッドのうちいずれかであること
  • GET
  • HEAD
  • POST
  1. 手動で設定できるリクエストヘッダは以下のいずれかであること(ユーザエージェントによって自動的に付与されるヘッダーを除く)
  • Accept
  • Accept-Langage
  • Content-Langage
  • Content-Type
  1. Content-Typeヘッダは以下のいずれかであること
  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

これらの条件を全て満たすものを 単純リクエスト と定義されています。

プリフライトリクエスト送信の流れ

単純リクエストでない場合のプリフライトリクエストが送られる流れを確認します。

  1. PUTリクエストでHTTPリクエストを送信
  2. 単純リクエストではないため、プリフライトリクエストが送られる
  3. プリフライトリクエストは、
  • OPTIONS api/ HTTP1.1
  • Access-Control-Request-Method: PUT
  • Origin: http://example.jp
    といったHTTPヘッダを追加してHTTPリクエストを飛ばす。このとき、実際の内容はまだ送られていない。
  1. プリフライトリクエストが送られてきたサーバは、
  • Access-Control-Allow-Method: PUT
  • Access-Control-Allow-Origin: http://example.com
    のように許可するHTTPメソッド・オリジンの情報を返却する。
  1. プリフライトリクエストで許可が得られたため、PUTリクエストが送信される
  2. あとは単純リクエストの手順と同じ

のような流れでプリフライトリクエストが送られます。

プリフライトリクエストを確認するためのコード

ローカルでプリフライトリクエストを確認できるようにコードを書いてみました。
https://github.com/shouyamamoto/study/tree/main/cors/preflight-cors

今回はフロント側をhttp://localhost:1234、バックエンドをhttp://localhost:8888で起動します。

フロント

単純リクエストのコードに追加して、以下のような削除する用の関数を追加しています。
methodにはDELETEを設定しているため、削除実行時には単純リクエストではないHTTPリクエストを送信することになります。

fetch(`http://localhost:8888/albums/delete/${id.value}`,{mode: cors, method: "DELETE"})
index.js
getButton = document.getElementById("getAlbum")
deleteButton = document.getElementById("deleteAlbum")
albums = document.getElementById("albums")

async function getAlbum() {
    const res = await fetch("http://localhost:8888/")
    const data = await res.json()

    while (albums.firstChild) {
        albums.removeChild(albums.firstChild)
    }

    data.forEach(d => {
        item = document.createElement("li")
        item.innerText = `ID: ${d.ID} アルバム名:${d.title} 作者:${d.artist} 価格:${d.price}`
        albums.appendChild(item)
    })
}

async function deleteAlbum() {
    id = document.getElementById("id")  
    await fetch(`http://localhost:8888/albums/delete/${id.value}`,{
        mode: "cors",
        method: "DELETE"
    })
    id.value = ""
}

getButton.addEventListener("click", getAlbum)
deleteButton.addEventListener("click", deleteAlbum)

バックエンド

バックエンド側では許可するオリジンに加えて新しく

AllowMethods: []string{"GET", "DELETE", "OPTIONS"}

を追加しています。
これでこのサーバはGET DELETE OPTIONSメソッドを許可することを明示しています。

main.go
func main() {
	e := echo.New()
	e.HideBanner = true

	arrowOrigins := []string{"http://localhost:1234"}
	e.Use(middleware.Logger())
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: arrowOrigins,
		AllowMethods: []string{"GET", "DELETE", "OPTIONS"},
	}))

	e.GET("/", GetAlbums)
	e.DELETE("/albums/delete/:id", Delete)

	e.Logger.Fatal(e.Start(":8888"))
}

まずはプリフライトリクエストが飛ばない、単純リクエストを確認します。
その次に単純リクエストではないリクエストを送信することで、プリフライトリクエストがどのように送信されているのか、request headerやresponse headerの内容を確認してみます。

動作確認

  1. 取得ボタンを押すとDBに保存されているデータが取得できる

  1. GETメソッドで単純リクエストに該当するため、プリフライトリクエストは行われていない。

    リクエストの結果
    request header
    request header
    response header
    response header

  1. 次にIDを指定して削除ボタンを押す。

  1. 今回はDELETEメソッドのため、プリフライトリクエストが送信されていることが確認できる。
    またその際のHTTPメソッドは、OPTIONSメソッドとして送信されている。
    preflight request
    preflight request

options method
options method


  1. プリフライトリクエストでお伺いを立てたあとに、実際のリクエスト内容を送信し、レスポンスを受け取る。
    request header
    request header

reqponse header
response header

資格情報を含むリクエストについて

資格情報とは以下を指します。

  • Cookie
  • 認証ヘッダー
  • TLS クライアント証明書

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

今回はCookie付きのリクエストを送る場合、どのようなリクエスト・レスポンスの設定が必要なのかを説明します。

リクエストでfetchを使う場合、credentialsオプションを設定することができます。
これはfetchがリクエストと一緒にCookie(資格情報)を送るべきかを指定するものです。

  • same-origin : デフォルト。クロスオリジンリクエストの場合はCookieを送信しない。同一オリジンの場合のみCookieなどの資格情報を送信することができる。
  • include : 資格情報を含むリクエストを、クロスオリジンの場合でも送信することができる。include を設定した場合、レスポンスを返すサーバから Access-Control-Allow-Credentials を要求する。
  • omit : 同一オリジンのリクエストの場合でも資格情報を送信しない。
    const res = await fetch("http://localhost:8081/users", {
        credentials: "include",
    })
    const data = await res.json()

このような単純リクエストの場合プリフライトリクエストは行われないですが、上記にも書いたようにレスポンスを含むサーバからAccess-Control-Allow-Credentialsを要求するため、これが含まれていないレスポンスを拒否してしまいます。

そのため、サーバ側ではAccess-Control-Allow-Credentialstrueとして返すように設定しなければいけません。

func main() {
	e := echo.New()
	e.HideBanner = true

	arrowOrigins := []string{"http://localhost:8080"}
	e.Use(middleware.Logger())
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: arrowOrigins,
		AllowCredentials: true, // ここ
	}))

	e.GET("/users", controller.GetUsers)

	e.Logger.Fatal(e.Start(":8081"))
}

またサーバ側は、資格情報付きのリクエストに返答する場合には、以下の条件を満たさなければならないと決まっています。

  • Access-Control-Allow-Origin"*" (ワイルドカード)を指定してはいけない。明示的にオリジンを指定する必要がある。
  • Access-Control-Allow-Headers"*" (ワイルドカード)を指定してはいけない。明示的にヘッダー名を指定する必要がある。
  • Access-Control-Allow-Methods"*" (ワイルドカード)を指定してはいけない。明示的にメソッド名を指定する必要がある。

まとめ

かなり長くなってしまいましたが、CORSについてまとめてみました。
オリジン、同一オリジンポリシー、単純リクエスト、各ヘッダーの設定などなどたくさん知ることはありましたが、一度理解してしまえば設定自体は簡単だったように思います。

また、MDNではCORSのエラーというまとめのページがあるため、CORS関係で詰まったときには訪れると良いのではないかと思います。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS/Errors#cors_error_messages

誰かのCORSの理解の役に立てれば幸いです。

参考

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#アクセス制御シナリオの例
https://www.youtube.com/watch?v=ryztmcFf01Y
https://ja.javascript.info/fetch-crossorigin

Discussion