XHRによるCORS通信時のpreflight問題について考える【.htaccessは要らないよ】
- 2020/03/15
概要
Javascript の XHR や fetch を使って非同期に通信を行う際、自分のドメインを超えて他のドメインにアクセスしたいことはよくありますよね。
XHR / fetch はいずれも他ドメインへの通信も可能 (正確には XHR は (通称) XHR Level2 以降) ですが、サーバ側でドメインが異なるアクセス元からの通信を許可してやる必要があります。これが CORS (Cross-Origin Resource Sharing) と呼ばれる決まり・技術です。
先日、ちょっと調べる機会がありましたのでその際に確認したことを備忘録を兼ねて共有したいと思います。
CORS を扱う際には .htaccess などでヘッダを追加する方法が取られますが、サーバサイドプログラムで出力すれば良かった (私自身もそうであるべきだと思っていた) のでそのあたりも含めてまとめます。
また、Javascript 側からヘッダを追加する (Authorization ヘッダなどが多い) 場合などに必要になる preflight リクエストについても言及します。
XHRによるCORSリクエストの発生条件
XHR の使い方は以下の記事をご確認下さい。以下の記事では同じドメイン内ですが、クロスドメインでもやり方は同じです。
・Javascriptでページ遷移せずにPOST送信する方法
Javascript のテスト用の基本となるコードは以下を使います。
POST リクエストを送信してレスポンスをコンソールに表示するだけです。
以下のいずれか1つでも該当すると違うオリジンと判断され、CORS による制限が発生します。
プロトコルが異なること
プロトコル、つまり、http:// か https:// かの違いがあるとドメインが同じでも違うオリジンとして扱われます。
ドメインが異なること
これは明らかですよね。このサイトのドメイン web.analogstd.com から google.com などへのリクエストがそうです。
注意点としてはサブドメインの違いも含まれることです。web.analogstd.com から test.analogstd.com などが該当します。
ポート番号が異なること
最後にポート番号が異なる場合もオリジンの違いとして検出されます。
普段はポート番号を省略していることが多いですが、サーバのポート番号を変更している場合は注意が必要です。
http のポート番号は80番、https のポート番号は443番で、省略しないと https://web.analogstd.com:443 のような表現になります。
サーバ側でのCORSの受け入れ準備
CORS でのリクエストはクライアント側の Javascript はなにもすることがありません。
自分自身のオリジンと変わることはないです。
しかし、サーバ側では CORS に対しての許可を出すヘッダの追加が必要です。
これには以下の2つの方法があります。
- .htaccess や Web サーバの設定ファイルでヘッダを追加する
- PHP などのサーバサイドプログラムでヘッダを出力する
.htaccessやWebサーバの設定ファイルでヘッダを追加する
一般的にはこの方法が良く使われていると思います。
私も今回詳しく検証するまでは Web サーバが CORS を許可するヘッダを出力する必要があるものだと思っていました。
ここではレンタルサーバでも簡単に変更できる .htaccess を使った方法でいきましょう。
許可するオリジンが一つだけか全ての場合は非常にシンプルですね。
しかし、ワイルドカードでの指定では Cookie のやり取りや BASIC 認証が使えない、セキュリティ的にどうなの?という問題もあるのでオススメしません。
大手 Web サービスの API はワイルドカード指定ですが、小規模なサービスでは使わない方が無難です。
(大手サービスはトークンで認証するヘッダも送信して情報へのアクセス許可をしています)
複数のオリジンからのアクセスを許可する場合は仕様としては半角スペース区切りで指定できますが、Google Chrome などのブラウザは複数のオリジン指定をブロックしてしまいます。
そんな場合は、.htaccess の環境変数に正規表現などでフィルタリングしたオリジンを代入して正規表現に一致するオリジンの場合にだけそのオリジンを付けたヘッダを返すようにしてやります。
上記の例だと、analogstd.com のメインとサブドメイン、sample.hoge.com からのアクセスを許可します。(http でも https でも良い)
PHPなどのサーバサイドプログラムでヘッダを出力する
続いては API のサーバ側処理を行う PHP などのサーバサイドプログラムでヘッダを出力する方法です。
この方法を紹介している記事は少なく、ダメだと思っていたんですが全く問題ないです。
MDN のサーバサイドアクセス制限というページで詳しく解説されています。
良く考えてみると、リクエストをブロックするのはサーバではなくブラウザなので当たり前ですね。
こちらの方法であればサーバでホワイトリストを作ってデータベース化するなど、複雑な対応もできますね。
複雑な条件になったとき、.htaccess では制御が呪文のような後から見て理解しがたいものになってしまうことも無くなります(笑)
やはり PHP などのプログラムでやる方が色々できそうですし、見通しが良いですね。
SQL と組み合わせれば柔軟な対応も可能です。
preflightリクエスト
さて、ここで preflight リクエストというものについて触れておきましょう。
preflightリクエストとは
preflight リクエストとは、単純なアクセス (後述) でない場合に実際にそのアクセスを行っても問題ないのかを本アクセスの前に確認するためのリクエストです。
この事前確認を行う preflight リクエストは HTTP ヘッダでサーバとやり取りし、XHR や fetch ではブラウザが自動的にリクエストを行うのでフロントエンドエンジニアは特に意識する必要はありません。
preflightリクエストが送られる条件
いつでも preflight リクエストが送られるわけではなく、単純なリクエストでない場合にのみ送られます。
単純なリクエストとは HTML の <FORM> で送信できるような内容を指し、以下をすべて満たすことリクエストになります。
- メソッド:GET, POST, HEAD のいずれか
-
追加のヘッダ:手動で設定したヘッダが以下に含まれるものだけ
- Accept
- Accept-Language
- Content-Language
- Content-Type
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
-
Content-Type ヘッダの値:以下の値のいずれか
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
特に認証もなくフォームデータを送信するような「普通の」リクエストが単純なリクエストです。
Authorization ヘッダで認証情報 (アクセストークンなど) を送ったり、JSON 形式でのリクエストボディを設定する場合は、単純なリクエストにはならず、preflight リクエストが自動的に追加されます。
preflightリクエストはOPTIONSメソッドで送信される
本アクセス前の確認を行う preflight リクエストは OPTIONS メソッドというあまり聞きなれないメソッドが使われます。
フレームワークを使っている場合は OPTIONS に対応していないことがあるらしい (未確認) ので、もしうまくいかなければフレームワークを使わない素の PHP で処理を行って下さい。
preflightリクエストに必要なヘッダ
サーバが返す必要があるヘッダは以下になります。
いずれもリクエストで使うものだけ返せばOKです。
- Access-Control-Allow-Origin: 許可するオリジン (すべての場合で必要)
- Access-Control-Allow-Headers: 許可するヘッダをカンマ区切りで全て列挙 (使わないヘッダが含まれもOK)
- Access-Control-Allow-Credentials: Cookie などの Credential 情報を扱う場合は true をセット
Content-Type に application/x-www-form-urlencoded か multipart/form-data、text/plain 以外を指定する場合には Access-Control-Allow-Headers に Content-Type を含める必要がありますが、上記の3つの場合は必要はありません。
また私は勘違いしていましたが、ブラウザが自動的にリクエストするからといって Web サーバも Apache や nginx のようなサーバソフトがレスポンスを処理するわけではありません。
ただの OPTIONS メソッドのリクエストとして PHP などのプログラムで処理すれば良いです。
加えて、以下のヘッダは必須ではありませんので必要に応じて追加して下さい。
- Access-Control-Allow-Methods: 許可するリクエストメソッド
- Access-Control-Max-Age: preflight リクエストのキャッシュ時間 [秒]
Access-Control-Allow-Methods には API が許可するリクエストメソッドをカンマ区切りで列挙します。
ただし、一般的な GET, POST, HEAD, OPTIONS メソッドは常に許可されるため、このヘッダは不要です。
PUT や PATCH、DELETE などのメソッドはこのヘッダで許可が必要です。
Access-Control-Max-Age は preflight の結果を一時的にキャッシュしておいても良い時間を指定します。
3600 を指定すれば1時間は preflight が送信されません。ヘッダを使わなければキャッシュされません。(毎回 preflight される)
サーバサイドのコード例
では最後にサーバサイドのテスト用の PHP コードを示します。
まとめ
以上、他ドメインへの CORS 通信についてでした。
分かりにくいですが、細かくテストすれば色々と勉強になることが多く面白いですね。
実際に使ってみないと分からないので、ぜひ、記事にあるテストコードで試してみ下さい。
私としては PHP でヘッダ返していいんだ・・・というのが最大の衝撃でした(笑)
PHP で返すならかなり楽に柔軟になるので皆さんもぜひ!