Analog Studio

XHRによるCORS通信時のpreflight問題について考える【.htaccessは要らないよ】

概要

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 リクエストを送信してレスポンスをコンソールに表示するだけです。

(function(){ const XHR = new XMLHttpRequest(); XHR.open("POST", "https://test.analogstd.com/cors/api.php", true); XHR.onload = () => { console.log(XHR.responseText); }; XHR.send(); })();

以下のいずれか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サーバの設定ファイルでヘッダを追加する

一般的にはこの方法が良く使われていると思います。
私も今回詳しく検証するまでは Web サーバが CORS を許可するヘッダを出力する必要があるものだと思っていました。

ここではレンタルサーバでも簡単に変更できる .htaccess を使った方法でいきましょう。

# CORSを許可するオリジンが一つだけの場合 Header set Access-Control-Allow-Origin https://web.analogstd.com # CORSを全てのオリジンに対して許可する場合 Header set Access-Control-Allow-Origin "*" # * は""で囲まなくてもOK Header set Access-Control-Allow-Origin * # CORSを複数のオリジンに対して許可する場合 # この場合は必ず "" で囲い、各オリジンは半角スペースで区切る # ただし、Google Chrome などのブラウザは複数オリジンの設定を拒否するので使わないように Header set Access-Control-Allow-Origin "https://web.analogstd.com https://analogstd.com" # 複数のドメインを扱う場合は以下のように正規表現と環境変数をうまく使う SetEnvIf Origin "^https?://(([0-9a-zA-Z]+\.)?analogstd\.com|sample\.hoge\.com)$" AllowOrigin=$0 Header set Access-Control-Allow-Origin %{AllowOrigin}e env=AllowOrigin

許可するオリジンが一つだけか全ての場合は非常にシンプルですね。

しかし、ワイルドカードでの指定では Cookie のやり取りや BASIC 認証が使えない、セキュリティ的にどうなの?という問題もあるのでオススメしません。
大手 Web サービスの API はワイルドカード指定ですが、小規模なサービスでは使わない方が無難です。
(大手サービスはトークンで認証するヘッダも送信して情報へのアクセス許可をしています)

複数のオリジンからのアクセスを許可する場合は仕様としては半角スペース区切りで指定できますが、Google Chrome などのブラウザは複数のオリジン指定をブロックしてしまいます。
そんな場合は、.htaccess の環境変数に正規表現などでフィルタリングしたオリジンを代入して正規表現に一致するオリジンの場合にだけそのオリジンを付けたヘッダを返すようにしてやります。
上記の例だと、analogstd.com のメインとサブドメイン、sample.hoge.com からのアクセスを許可します。(http でも https でも良い)

PHPなどのサーバサイドプログラムでヘッダを出力する

続いては API のサーバ側処理を行う PHP などのサーバサイドプログラムでヘッダを出力する方法です。
この方法を紹介している記事は少なく、ダメだと思っていたんですが全く問題ないです。
MDN のサーバサイドアクセス制限というページで詳しく解説されています。

良く考えてみると、リクエストをブロックするのはサーバではなくブラウザなので当たり前ですね。

こちらの方法であればサーバでホワイトリストを作ってデータベース化するなど、複雑な対応もできますね。
複雑な条件になったとき、.htaccess では制御が呪文のような後から見て理解しがたいものになってしまうことも無くなります(笑)

<?php // PHPではヘッダの出力はheader()関数で簡単にできる $allow_origin = 'https://web.analogstd.com'; header('Access-Control-Allow-Origin: '. $allow_origin); // 複数のオリジンを正規表現で確認する場合 // XHRなどではOriginヘッダがブラウザからリクエストで付けられる $request_origin = $_SERVER['HTTP_ORIGIN']; $allow_regex = '/^https?:\/\/((\w+\.)?analogstd\.com|sample\.hoge\.com)$/i'; if(preg_match($allow_regex, $request_origin, $m)){ header('Access-Control-Allow-Origin: '. $m[0]); } ?>

やはり PHP などのプログラムでやる方が色々できそうですし、見通しが良いですね。
SQL と組み合わせれば柔軟な対応も可能です。

preflightリクエスト

さて、ここで preflight リクエストというものについて触れておきましょう。

preflightリクエストとは

preflight リクエストとは、単純なアクセス (後述) でない場合に実際にそのアクセスを行っても問題ないのかを本アクセスの前に確認するためのリクエストです。

この事前確認を行う preflight リクエストは HTTP ヘッダでサーバとやり取りし、XHR や fetch ではブラウザが自動的にリクエストを行うのでフロントエンドエンジニアは特に意識する必要はありません。

preflightリクエストが送られる条件

いつでも preflight リクエストが送られるわけではなく、単純なリクエストでない場合にのみ送られます。
単純なリクエストとは HTML の <FORM> で送信できるような内容を指し、以下をすべて満たすことリクエストになります。

特に認証もなくフォームデータを送信するような「普通の」リクエストが単純なリクエストです。
Authorization ヘッダで認証情報 (アクセストークンなど) を送ったり、JSON 形式でのリクエストボディを設定する場合は、単純なリクエストにはならず、preflight リクエストが自動的に追加されます。

preflightリクエストはOPTIONSメソッドで送信される

本アクセス前の確認を行う preflight リクエストは OPTIONS メソッドというあまり聞きなれないメソッドが使われます。

フレームワークを使っている場合は OPTIONS に対応していないことがあるらしい (未確認) ので、もしうまくいかなければフレームワークを使わない素の PHP で処理を行って下さい。

preflightリクエストに必要なヘッダ

サーバが返す必要があるヘッダは以下になります。
いずれもリクエストで使うものだけ返せばOKです。

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 には API が許可するリクエストメソッドをカンマ区切りで列挙します。
ただし、一般的な GET, POST, HEAD, OPTIONS メソッドは常に許可されるため、このヘッダは不要です。
PUT や PATCH、DELETE などのメソッドはこのヘッダで許可が必要です。

Access-Control-Max-Age は preflight の結果を一時的にキャッシュしておいても良い時間を指定します。
3600 を指定すれば1時間は preflight が送信されません。ヘッダを使わなければキャッシュされません。(毎回 preflight される)

サーバサイドのコード例

では最後にサーバサイドのテスト用の PHP コードを示します。

<?php // 送信元オリジンはOriginリクエストヘッダで送信されてくる $request_origin = $_SERVER['HTTP_ORIGIN']; // 許可するオリジン $allow_regex = '/^https?:\/\/((\w+\.)?analogstd\.com|sample\.hoge\.com)$/i'; // 許可しないオリジンからのアクセスを拒否する if(!preg_match($allow_regex, $request_origin, $m)){ http_response_code(403); exit; } // preflightも通常のリクエストもどちらでも必要なAllow-Originヘッダ header('Access-Control-Allow-Origin: '. $m[0]); // preflightリクエストなら必要なヘッダを返す // preflightの時は"Access_Control_Request_Method"リクエストヘッダで要求するメソッドが送信されてくる if($_SERVER['REQUEST_METHOD'] === "OPTIONS" && isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])){ // GET, POST, OPTIONS の場合はAllow-Methodsヘッダは不要 header('Access-Control-Allow-Methods: GET, POST'); // 許可するヘッダ (必要なら) header('Access-Control-Allow-Headers: Content-Type, Authorization'); // Cookie送信などのCredential情報をやり取りする場合 (必要なら) header('Access-Control-Allow-Credentials: true'); // preflightリクエストをキャッシュする時間 [秒] header('Access-Control-Max-Age: 60'); exit; } if($_SERVER['REQUEST_METHOD'] === 'POST'){ $response = array('Received POST Data: '); foreach($_POST as $key => $value){ $response[] = "{$key}: {$value}"; } exit(implode("\n", $response)); } if($_SERVER['REQUEST_METHOD'] === 'GET'){ exit('Received GET Request'); } ?>

まとめ

以上、他ドメインへの CORS 通信についてでした。
分かりにくいですが、細かくテストすれば色々と勉強になることが多く面白いですね。

実際に使ってみないと分からないので、ぜひ、記事にあるテストコードで試してみ下さい。

私としては PHP でヘッダ返していいんだ・・・というのが最大の衝撃でした(笑)
PHP で返すならかなり楽に柔軟になるので皆さんもぜひ!