CORS の挙動の観察と preflight request の検証

CORS と preflight request について自分の理解を深めるために検証してみることにしました。

その前に、各事項について整理します。

CORS について

  • CORS (Cross-Origin Resource Sharing):
    • あるオリジンで動作するアプリケーションから、異なるオリジンのリソースにアクセス権をブラウザに与える仕組み
  • オリジン:
    • プロトコル・ホスト(ドメイン)・ポートから成る
    • どれか一つでも異なっていれば「異なるオリジン」と判断される
  • 同一オリジンポリシー (same-origin policy):
    • ブラウザはセキュリティ上の観点からアプリケーションからの異なるオリジンへの HTTPリクエスト を制限している
      • XMLHttpRequest (AJAX) や Fetch API が対象となる
    • 異なるオリジンへのアクセスを許可するには、 HTTPレスポンスヘッダ に決められたプロパティをセットする必要がある
      • サーバ側の対応
    • さらに、 HTTPリクエスト が一定の条件をクリアしていない場合は preflight request が送信される(後述「preflight request が送信される条件」)
      • クライアント側の対応
      • 今から送る HTTPリクエスト を本当に送信しても良いのか、事前に確認するためのもの

preflight request が送信される条件

HTTPリクエスト は条件によって preflight request が送信される場合と、そうではない場合( simple request )がある。

simple request の条件は、以下の全ての条件を満たす必要がある。

  1. メソッドが以下のいずれか
    • GET
    • HEAD
    • POST
  2. 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
  3. リクエストに使用されるどの XMLHttpRequestUpload にもイベントリスナーが登録されていないこと
  4. リクエストに ReadableStream オブジェクトが使用されていないこと

※上記の条件は以下の記事を参考にしましたが、全てを検証したわけではありません。

上記の条件に該当しない例としては以下のようなケース。

  • REST API 通信のために HTTPリクエストヘッダ に Content-Type: application/json を付ける
  • ユーザ情報取得のため Authorization を付ける

CORS の許可

異なるオリジンでの通信を許可する場合は、以下の設定が必要。

クライアントサイド

  • XHR の場合: 特になし
  • Fetch API の場合: mode: cors を付与する必要あり
    • CORS の場合、自動的に Origin ヘッダが付与される
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-Typeapplication/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 となります。

simple request のサンプル。 Devtools で HTTPリクエスト が1回だけであることが確認できる。
simple request のサンプル。 Devtools で HTTPリクエスト が1回だけであることが確認できる。

preflight request の観察

次に preflight request を観察します。「Test flight」のテキストフィールドに何らかの入力がある状態(デフォルトでセット済み)、「with preflight request」ボタンを押下すると HTTPリクエスト が投げられます。

preflight request のサンプル。 Devtools で HTTPリクエスト が2回になっていることが確認できる。また、 preflight request は OPTIONSメソッド で投げられていることも確認できる。
preflight request のサンプル。 Devtools で HTTPリクエスト が2回になっていることが確認できる。また、 preflight request は OPTIONSメソッド で投げられていることも確認できる。

1回のボタン押下で2つの HTTPリクエスト が飛んでいることが確認できます。1つ目は OPTIONSメソッド なのでこれが preflight request 。

preflight request のサンプル。 Devtools で HTTPリクエスト が2回になっていることが確認できる。こちらは POSTメソッド なので本体の HTTPリクエスト 。
preflight request のサンプル。 Devtools で HTTPリクエスト が2回になっていることが確認できる。こちらは POSTメソッド なので本体の HTTPリクエスト 。

2つ目が本体の POSTリクエスト 。

HTTPリクエストヘッダ の Origin

CORS の場合、 HTTPリクエストヘッダ に自動的に Origin ヘッダが付与されるという点も確認しておきます。

CORS で HTTPリクエストヘッダ に付与された Origin
CORS で HTTPリクエストヘッダ に付与された Origin

確かに存在します。

HTTPレスポンスヘッダ から Access-Control-Allow-Origin を除去

次に、 HTTPレスポンスヘッダ から Access-Control-Allow-Origin を除去してみます。

そのために作成したスイッチの Access-Control-Allow-Origin をオフにして「with preflight request」ボタンを押下します。

Access-Control-Allow-Origin がないためエラーで弾かれた HTTPリクエスト
Access-Control-Allow-Origin がないためエラーで弾かれた HTTPリクエスト

HTTPレスポンスヘッダ に Access-Control-Allow-Origin がなく、エラーになったことが確認できました。

Access-Control-Allow-Origin がないために表示された console のログ
Access-Control-Allow-Origin がないために表示された console のログ

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メソッド を受け付けるメソッドの一覧から外しました。

OPTIONSメソッド がPHPフレームワークのルータ機能で弾かれて 403 forbidden になった
OPTIONSメソッド がPHPフレームワークのルータ機能で弾かれて 403 forbidden になった

想定通り、 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 をセット。

HTTPリクエスト が1回しか飛んでおらず、 simple request になったことが確認できた。
HTTPリクエスト が1回しか飛んでおらず、 simple request になったことが確認できた。

この状態で「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 プロパティをセットしてみます。

HTTPリクエストヘッダ のみ Access-Control-Allow-Credentials: true がセットされているので、ブロックされた
HTTPリクエストヘッダ のみ Access-Control-Allow-Credentials: true がセットされているので、ブロックされた

クライアント側のみなのでブロックされました。

HTTPリクエストヘッダ のみ Access-Control-Allow-Credentials: true のときのログ
HTTPリクエストヘッダ のみ Access-Control-Allow-Credentials: true のときのログ

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 をヘッダのプロパティとして許可しないとエラーになってしまいました。

クライアント・サーバ双方で Access-Control-Allow-Credentials: true のとき通信。エラーにならず通信できるようになった。
クライアント・サーバ双方で Access-Control-Allow-Credentials: true のとき通信。エラーにならず通信できるようになった。

今度はエラーにならずに通信できました。


駆け足でしたが、大体 CORS と preflight request の観察ができたと思います。

ちなみに、今更と言えば今更ですが、Vue.js内のaxiosからPOSTすると2回POSTされてしまうの話は今から見ると preflight request が飛んでいますね、これ。

参考

オリジン

CORS

レスポンスヘッダ

テストフライト

Chrome Devtools

上記のように Chrome の Devtools では preflight request が表示されないという情報がありましたが、現時点(version: 86.0.4240.75)では表示されていました(記事内のスクリーンショットは Vivaldi のものですが、 Chrome でも確認済み)。

上述バージョンでは chrome://flags/#out-of-blink-cors のフラグも存在しないため、詳細不明。

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。