CORS と preflight request について自分の理解を深めるために検証してみることにしました。
その前に、各事項について整理します。
CORS について
- CORS (Cross-Origin Resource Sharing):
- あるオリジンで動作するアプリケーションから、異なるオリジンのリソースにアクセス権をブラウザに与える仕組み
- オリジン:
- プロトコル・ホスト(ドメイン)・ポートから成る
- どれか一つでも異なっていれば「異なるオリジン」と判断される
- 同一オリジンポリシー (same-origin policy):
- ブラウザはセキュリティ上の観点からアプリケーションからの異なるオリジンへの HTTPリクエスト を制限している
- XMLHttpRequest (AJAX) や Fetch API が対象となる
- 異なるオリジンへのアクセスを許可するには、 HTTPレスポンスヘッダ に決められたプロパティをセットする必要がある
- サーバ側の対応
- さらに、 HTTPリクエスト が一定の条件をクリアしていない場合は preflight request が送信される(後述「preflight request が送信される条件」)
- クライアント側の対応
- 今から送る HTTPリクエスト を本当に送信しても良いのか、事前に確認するためのもの
- ブラウザはセキュリティ上の観点からアプリケーションからの異なるオリジンへの HTTPリクエスト を制限している
preflight request が送信される条件
HTTPリクエスト は条件によって preflight request が送信される場合と、そうではない場合( simple request )がある。
simple request の条件は、以下の全ての条件を満たす必要がある。
- メソッドが以下のいずれか
GET
HEAD
POST
- HTTPリクエストヘッダ に含まれるものが以下のいずれかに該当する
- ユーザーエージェントによって自動的に設定されたヘッダ (例:
Connection
,User-Agent
) Accept
Accept-Language
Content-Language
Content-Type
(値が以下のいずれかのみ)application/x-www-form-urlencoded
multipart/form-data
text/plain
DPR
Downlink
Save-Data
Viewport-Width
Width
- ユーザーエージェントによって自動的に設定されたヘッダ (例:
- リクエストに使用されるどの
XMLHttpRequestUpload
にもイベントリスナーが登録されていないこと - リクエストに
ReadableStream
オブジェクトが使用されていないこと
※上記の条件は以下の記事を参考にしましたが、全てを検証したわけではありません。
上記の条件に該当しない例としては以下のようなケース。
- REST API 通信のために HTTPリクエストヘッダ に
Content-Type: application/json
を付ける - ユーザ情報取得のため
Authorization
を付ける
CORS の許可
異なるオリジンでの通信を許可する場合は、以下の設定が必要。
クライアントサイド
- XHR の場合: 特になし
- Fetch API の場合:
mode: cors
を付与する必要あり- CORS の場合、自動的に
Origin
ヘッダが付与される
- CORS の場合、自動的に
fetch('https://example.com/fetch/api', {
mode: 'cors'
});
サーバサイド
サーバの HTTPレスポンスヘッダ に以下を追加する。
- (必須)
Access-Control-Allow-Origin
- シンプルなケースでは
Access-Control-Allow-Origin: *
というワイルドカード指定が可能- サブドメインなど部分的な指定は不可 (例:
Access-Control-Allow-Origin: *.example.com
)
- サブドメインなど部分的な指定は不可 (例:
- 通常はアクセス元のリソースがあるオリジンを指定する (例:
Access-Control-Allow-Origin: https://example.com
)
- シンプルなケースでは
- (必要な場合)
Access-Control-Allow-Headers
- 例:
Access-Control-Allow-Headers: X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept
- 例:
- (必要な場合)
Access-Control-Request-Method
- 例:
Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
- 例:
- (必要な場合)
Access-Control-Max-Age
- preflight request の情報をブラウザにキャッシュさせる期間。単位は秒。
-1
に設定すると毎回 preflight request を送信する
Cookie などを送信する場合
Cookie などの資格情報を送信する場合は HTTPレスポンスヘッダ に以下が追加。
Access-Control-Allow-Credentials: true
- この値が
true
のとき、Access-Control-Allow-Origin
はワイルドカード使用不可となる
- この値が
検証
検証用のプロジェクトを React.js (HTTPクライアント に axios 使用) + DietCube で作成。
挙動としては以下の挙動。基本的にはそれぞれの HTTPリクエスト を投げて、バックエンドはその HTTPリクエスト と HTTPレスポンス の情報を JSON で返却するシンプルな作りです。
- 「simple request」ボタン: axios から
GET
メソッド でデータを取得。 HTTPレスポンスヘッダ にContent-Type: plain/text
をセットすることで simple request の条件を満たしている - 「with preflight request」ボタン: axios から
POST
メソッド でデータを送信。Content-Type
はapplication/json
- PHP側では、どちらの HTTPリクエスト も HTTPリクエストヘッダ と HTTPレスポンスヘッダ 、「with preflight request」の場合は HTTPリクエスト で送信されたデータ(ボディ)も含めて各種情報を JSON で返却する
- React 側では各ボタン下のテキストエリアに返却された JSON データを(HTTPリクエスト 送信ごとに)追記
simple request
画面の「simple request」ボタンを押下すると、 GET
メソッド の HTTPリクエスト を発行します。このとき、 HTTPレスポンスヘッダ には上述の通り Content-Type: plain/text
をセットしているため、 simple request となります。
preflight request の観察
次に preflight request を観察します。「Test flight」のテキストフィールドに何らかの入力がある状態(デフォルトでセット済み)、「with preflight request」ボタンを押下すると HTTPリクエスト が投げられます。
1回のボタン押下で2つの HTTPリクエスト が飛んでいることが確認できます。1つ目は OPTIONS
メソッド なのでこれが preflight request 。
2つ目が本体の POST
リクエスト 。
HTTPリクエストヘッダ の Origin
CORS の場合、 HTTPリクエストヘッダ に自動的に Origin
ヘッダが付与されるという点も確認しておきます。
確かに存在します。
HTTPレスポンスヘッダ から Access-Control-Allow-Origin を除去
次に、 HTTPレスポンスヘッダ から Access-Control-Allow-Origin
を除去してみます。
そのために作成したスイッチの Access-Control-Allow-Origin
をオフにして「with preflight request」ボタンを押下します。
HTTPレスポンスヘッダ に Access-Control-Allow-Origin
がなく、エラーになったことが確認できました。
Devtools の console タブでもエラーが表示されていることが確認できます。
Access to XMLHttpRequest at ‘http://localhost:8999/icanfly/api’ from origin ‘http://localhost:3000’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
PHPフレームワークのルータ機能でメソッドを制限
次に、 PHP 側で受け付けるメソッドを制限してみます。
// 変更前
[['GET', 'POST', 'OPTIONS'], 'icanfly/api', 'Flight::mirrorJson'],
// 変更後
[['GET', 'POST'], 'icanfly/api', 'Flight::mirrorJson'],
OPTIONS
メソッド を受け付けるメソッドの一覧から外しました。
想定通り、 preflight request が弾かれて 403 forbidden になりました。
Content-Type: application/x-www-form-urlencoded にしてみる
次に、 HTTPリクエストヘッダ と HTTPレスポンスヘッダ の双方に Content-Type: application/x-www-form-urlencoded
をセットしてみます。
こうすると simple request とみなされるはずです。
axios({
method: 'POST',
url: `${props.props.origin.protocol}://${props.props.origin.hostname}:${props.props.origin.port}${props.props.origin.path2}`,
data: postData,
// 追加
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then((response) => { /* 略 */ })
プログラムでは、まず axios の中で指定の Content-Type
をセット。
$headersArray = [
'Access-Control-Allow-Origin' => explode(':', $this->config['appconfig']['url'])[0] . ':' . explode(':', $this->config['appconfig']['url'])[1] . ':3000',
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Accept',
'Content-Type' => 'application/x-www-form-urlencoded', // 修正
'X-Identified-Header' => $_SERVER['REQUEST_METHOD'] . ', ' . file_get_contents('php://input')
];
PHP 側も指定の Content-Type
をセット。
この状態で「with preflight request」ボタンを押下すると、 HTTPリクエスト は1回のみとなりました。
simple request の条件を満たし、 preflight request が飛んでいないことを確認できました。
withCredentials をセットする (クライアントのみ)
最後に、 Cookie のやり取りを許可する Access-Control-Allow-Credentials
プロパティ関連を見てみます。
axios({
method: 'POST',
url: `${props.props.origin.protocol}://${props.props.origin.hostname}:${props.props.origin.port}${props.props.origin.path2}`,
data: postData,
// 追加
headers: {
'Access-Control-Allow-Credentials': true
}
}).then((response) => { /* 略 */ })
まず axios 側だけに Access-Control-Allow-Credentials
プロパティをセットしてみます。
クライアント側のみなのでブロックされました。
console でもエラーが表示されています。
Access to XMLHttpRequest at ‘http://localhost:8999/icanfly/api’ from origin ‘http://localhost:3000’ has been blocked by CORS policy: Request header field access-control-allow-credentials is not allowed by Access-Control-Allow-Headers in preflight response.
access-control-allow-credentials is not allowed
としっかり怒られていますね。
withCredentials をセットする (サーバもセット)
今度はサーバ側、 HTTPレスポンスヘッダ でも Access-Control-Allow-Credentials
プロパティをセットしてみます。
$headersArray = [
'Access-Control-Allow-Origin' => explode(':', $this->config['appconfig']['url'])[0] . ':' . explode(':', $this->config['appconfig']['url'])[1] . ':3000',
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Accept, Access-Control-Allow-Credentials', // Access-Control-Allow-Credentials を追加
'Access-Control-Allow-Credentials' => 'true', // セット
'Content-Type' => 'application/json',
'X-Identified-Header' => $_SERVER['REQUEST_METHOD'] . ', ' . file_get_contents('php://input')
];
今回は Access-Control-Allow-Credentials
そのものだけでなく、 Access-Control-Allow-Headers
でそもそも Access-Control-Allow-Credentials
をヘッダのプロパティとして許可しないとエラーになってしまいました。
今度はエラーにならずに通信できました。
駆け足でしたが、大体 CORS と preflight request の観察ができたと思います。
ちなみに、今更と言えば今更ですが、Vue.js内のaxiosからPOSTすると2回POSTされてしまうの話は今から見ると preflight request が飛んでいますね、これ。
参考
オリジン
CORS
- オリジン間リソース共有 (CORS) – HTTP | MDN
- Preflight request (プリフライトリクエスト) – MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN
- CORS: OPTIONSリクエスト(preflight request)を避ける – Qiita
- CORS とか Preflight とかよくわかんないよな – くろのて
- CORSのプリフライトリクエスト(OPTIONメソッド)はAPI Keyの認証なしでOKにしておかないと失敗する話 | フューチャー技術ブログ
- CORS(Cross-Origin Resource Sharing)について整理してみた | Developers.IO
- CORS の プリフライト・リクエストを発生させて観察する | ラボラジアン
- CORS のプリフライト・リクエストを観察する
レスポンスヘッダ
テストフライト
Chrome Devtools
- Chrome 79 以降、Developer Tools でCORS のPre-flight Request が表示されなくなっていた | Nijot Tech Blog
- Chrome76からCORSのpreflight(OPTION)リクエストが見えない – log.pocka.io
上記のように Chrome の Devtools では preflight request が表示されないという情報がありましたが、現時点(version: 86.0.4240.75)では表示されていました(記事内のスクリーンショットは Vivaldi のものですが、 Chrome でも確認済み)。
上述バージョンでは chrome://flags/#out-of-blink-cors
のフラグも存在しないため、詳細不明。