· 9分で読了

CORSとCookieに向き合う

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

はじめに

CORSとCookieはフロントエンドにおいてかなり重要な問題だが、開発の現場ではフロントエンドとバックエンドのドメインが同じであることが多いため、これらの問題を気にすることは滅多にない。あるいはバックエンドに Access-Control-Allow-Origin: * を全開に設定してもらうだけで済ませてしまい、背後で動いている仕組みを理解しようとすることはほとんどない。

この問題については、実は MDN に非常に詳しい解説がある。そのため、この記事では主に要点の整理と、実際の開発時によく遭遇する問題についてまとめていく。

同一オリジンポリシー(same-origin policy)

JavaScriptがWebページ上で勝手な振る舞いをするのを防ぐため、同一オリジンポリシーによって、特定の特定のリソースやコードは**同一オリジン(同源)**である場合のみアクセスが許可されると定められている。

では、同一オリジンとは何だろうか?ある document のオリジンは、protocol(プロトコル)、host(ホスト)、port(ポート)によって定義される。つまり、ドキュメント1が http://kalan.com 由来で、ドキュメント2が https://kalan.com 由来であれば、それらは同一オリジンとは見なされない。では、サブドメインの場合はどうだろうか?例えば https://api.foobar.comhttps://app.foobar.com のようなケースだ。これらはホストが異なるため、やはり同一オリジンとは見なされない。

一方で、もともとクロスオリジンで取得可能なリソースもある:

  • <img />
  • <video />, <audio />
  • <iframe />:ヘッダーを定義することで他者からの埋め込みを防止できる
  • <link rel="stylesheet" href /> で読み込まれるCSSスタイルシート
  • <script src="" /> で読み込まれるJavaScript

しかし、コードを介して送信されるクロスオリジンリクエスト(FetchやXHRなど)は、同一オリジンポリシーの制限を受けることになる。

言うまでもなく、このようなポリシーは厳格すぎる。すべてを同一オリジンポリシーの制約下に閉じ込めてしまっては、フロントエンドとバックエンドの開発が極めて困難になるし、XHRを使って他のSDKのAPIを利用することもできなくなってしまう。そこで登場したのがCORS(Cross-Origin Resource Sharing)という仕組みだ。

CORS(Cross-Origin Resource Sharing)

多くの人はCORSをフロントエンドだけが理解していればよい知識だと思っている。だがCORSは通常、バックエンド側で関連するヘッダーを設定し、その背後にある意味を正しく理解していなければ正常に機能させることはできない。

では、クロスオリジンリクエストはどのように動作するのだろうか?主に2つのヘッダーによってアクセスコントロールが行われる。OriginAccess-Control-Allow-Origin だ。

リクエスト送信時の Origin と、レスポンスヘッダー内の Access-Control-Allow-Origin の値が一致しているか、あるいは Access-Control-Allow-Origin: *(任意のドメインからのアクセスを許可することを意味する)になっていればアクセスできる。

CORSの条件を満たしていない場合、以下のようなメッセージが表示される:

2019-01-18 10 12 54

返ってきたオブジェクトを読み取ろうとすると、さらにwarningが表示される。

では…提示されたメッセージに従って、fetchのmodeを no-cors に変更するとどうなるだろうか?

確かに、鬱陶しいエラーメッセージは消え去った。しかし、状況が好転したわけではない。

no-cors は万能薬ではない。このモードを使ったからといってCORSの門戸が開かれるわけではなく、リクエストが成功して結果を受け取れるわけでもない。そのため、SyntaxError: Unexpected end of input というエラーが発生する。このモードは通常、Service Workerと組み合わせて使用されるものだ。

この実験から分かるように、CORSの封印を解く方法はただ1つ、サーバー側で適切な Access-Control-Allow-Origin(ホストがオリジンと一致しているか、または *)を付与することだけだ。

また、CORSの仕組みはJavaScriptがXHRやfetchを送信するときにのみ機能する。一般的なcurlやPostmanにはこのような仕組みがないため、APIエンドポイントのテスト時には見落とされがちで、フロントエンドとバックエンドの間でAPIの動作確認に食い違いが生じる原因になりやすい。

クロスオリジンリクエストには、プリフライト(preflight)が発生しないものと発生するものがある。MDNにはその条件がかなり明確に記載されている:

  1. メソッドがGET、HEAD、POSTのいずれかであること
  2. ユーザーエージェントによって自動的に設定されるヘッダーおよび特定のヘッダー以外に、他のヘッダーを含まないこと。許容されるヘッダー一覧
  3. Content-Type がある場合(レスポンスヘッダーではなくリクエストヘッダーであることに注意)、以下の値のいずれかであること:application/x-www-form-urlencodedtext/plainmultipart/form-data

つまり、以上の条件を満たさない場合は、プリフライトリクエストが送信されることになる。

ここで、プリフライトの要件を満たすために(application/x-www-form-urlencodedtext/plainmultipart/form-data 以外にするために)、Content-Typeapplication/json に変更してみる。

Preflight(プリフライト)

プリフライトとは、本番のリクエストを送信する前に、まずHTTP OPTIONSメソッドを使って別のドメインに「挨拶」を送り、問題がないことを確認してから本番のリクエストを送る仕組みのことだ。この条件がトリガーされると、対応すべき作業が一気に面倒になる。

  1. 同じAPIエンドポイントにOPTIONSメソッドを追加し、さらにCORSの要件を満たすようにAccess-Control-Allow-Originを設定しなければならない
  2. Access-Control-Allow-Headers を追加し、条件に含まれていないヘッダーをすべて網羅しなければならない。そうでなければ通過できない。

プリフライトチェックを通過できなかった場合、以下のようなエラーメッセージが表示される:

Access to fetch at 'http://localhost:3001/trigger-preflight' from origin 'http://localhost:3000' has been blocked by CORS policy:
Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

あるいは、OPTIONS のレスポンスヘッダーに Access-Control-Allow-Origin を追加していない場合:

Access to fetch at 'http://localhost:3001/trigger-preflight' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

成功した場合は、ネットワークタブに2つのリクエストが表示される。1つはOPTIONSで、もう1つが実際のリクエストだ。

では、独自のカスタムヘッダーを追加した場合はどうなるだろうか?MDNの定義に基づけばこれもプリフライトリクエストをトリガーするはずだ。X-Access-Token を追加して何が起きるか見てみよう。

fetch("http://localhost:3001/trigger-preflight", {
  headers: { "X-Access-Token": "dontbeserious" },
})
  .then(res => res.json())
  .then(log)
2019-01-18 11 20 07

案の定、プリフライトを通過できない。通過させるには、X-Access-TokenAccess-Control-Allow-Headers に追加する必要がある。

認証情報を含むリクエスト(Credentialed requests)

Cookieはクロスオリジンで直接渡すことはできない。つまり、異なるオリジン間でCookieを勝手に共有・アクセスすることはできないのだ。これができたら大混乱に陥ってしまう。ただし、ドメインAからドメインBへのリクエストを送信し、ドメインBがCookie情報を返してきた場合、ドメインAのブラウザ上にはドメインB用のCookieとして保存される。しかし、withCredentials または credentials: 'include' を設定していなければ、サーバーから Set-Cookie が返ってきても書き込まれることはない。以下の画像のような状態だ:

2019-01-18 1 10 06 2019-01-18 1 10 18

通常、再びドメインBのAPIを呼び出す際、Cookieは自動的には送信されない。この場合、XHRwithCredentials を設定するか、fetch のオプションで { credentials: 'include' } を指定する必要がある。これもクロスオリジンリクエストであるため、CORSの要件に従って Access-Control-Allow-Origin を追加しなければならない。

fetch(`${hostname}/cookie`, {
  method: "POST",
  credentials: "include",
})
Access to fetch at 'http://localhost:3001/cookie' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

セキュリティ上の懸念を排除するため、ブラウザの仕様により Access-Control-Allow-Origin*(ワイルドカード)を指定することは禁じられている。

Access to fetch at 'http://localhost:3001/cookie' from origin 'http://localhost:3000' 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'.

しかし、それだけではまだ不十分だ。ブラウザは Access-Control-Allow-Credentials のないレスポンスを自動的に拒否する。そのため、認証情報をクロスオリジンのサーバーに送信できるようにするには、別途 Access-Control-Allow-Credentials: true を付与する必要がある。すべて正しく設定できていれば、以下の画像のようにRequest Cookie内にCookieが正常に送信されていることが確認できるはずだ。

2019-01-18 1 11 37

さて、これらをすべて正しく設定したとしても、まだサーバーにCookieが送られないケースがある。その原因としては、以下の状況が考えられる:

1. ユーザーが該当ドメインのCookieをブロックしている

ユーザーがドメインをブラックリストに入れている可能性があり、Cookieが正常に送信されない。

解決策:

  • ドメインを変更する
  • なぜユーザーにブロックされたのか自省する

2. ユーザーが外部WebサイトのCookieをすべてブロックしている(サードパーティCookieのブロック)

Safariではこれが有効になっていることがあり、僕もデバッグ時にかなり痛い目を見た。

2019-01-18 1 13 21

おわりに

CORSの対応は骨が折れる割に報われない作業だ。特にAccess-Control-Allow-OriginやAccess-Control-Allow-Credentialsを付与し忘れた場合、CI/CDを回してデプロイし直すのにまた丸一日かかったりする。今回はよくある問題を整理してみた。今後また似たような状況に直面したときに、どう対処すべきかの手引きになれば幸いだ。

もっとも、最近ではAWS API Gatewayなどを使えば、メインのアプリケーションコードに手を加えることなく必要なヘッダーを付与できるし、あるいは同じドメイン配下にプロキシを1枚挟んで根本的に解決してしまうという手もある。

参考記事

関連記事

他のトピックを探索