2019-12-07

CORS とか Preflight とかよくわかんないよな

@aki_yok さんの DRF本 レビューをしていて Preflight について記述があったんですが、 自分が しっかり理解できていないような気がしたので CORS を絡めて簡単にまとめてみました。

昔話 👹

はるか昔 🐉 XMLHttpRequest (以降 XHR という) により私達は非同期通信を手に入れました。 XMLHttpRequest - Web API | MDNXMLHttpRequest (XHR) オブジェクトは、サーバーと対話するために使用されます。ページ全体を更新する必要なしに、データを受け取ることができます。これでユーザーの作業を中断させることなく、ウェブページの一部を更新することができます。https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest

しかし、この便利機能は その強力さ故に 同一生成元ポリシー というセキュリティ機構で守られていたのです。

実は XHR には 規格みたいなものがあって、 XHR Level2 と呼ばれる新規格がでるまで、 初期の XHR ではクロスオリジン通信が行えませんでした 🙅‍

この間、クロスオリジンの通信として用いられていたのが JSONP と呼ばれる JavaScriptの読み込みをうまく利用した非同期通信です。 これは厳密には JSON でもなければ XHR でもなく、 <script> タグを動的に挿入することで データを JavaScript ファイルとして読み込み、 呼び出し側が指定したコールバック関数を実行することにより読み込んだデータを変数に格納するという、いわばハックです😎

一時期 WebAPI に &callback=_ みたいなパラメータ指定してたじゃないですか。あれがJSONPです。 あの頃はみんな jQueryで使ってたんじゃないかな(どうでもいい)

とはいえ、JSONデータではなく、JavaScriptのコードが問答無用で実行されてしまうので信頼できるサーバとしか通信できないなど セキュリティ的にはあまりよろしくありませんでした

info
  • JSONP でデータをやり取りするためにはサーバ、クライアント両方の対応が必要で、 現在ではこの技術自体が絶滅寸前なのでクライアント側で攻撃を受けることはほぼないでしょう。
  • サービスを受けることもできないと思いますが..

この状況を解消すべく登場したのが XHR Level2 です。 こいつを使うことでサーバ側が許可したオリジンと通信することが可能となりました。やったー👼 (どうやってやるかは後述)

その後の2014年に Level2 は Level1 と統合される形で消滅しました 💥

そして、今では Fetch API と呼ばれる新規格が登場し、 XHR はもう引退の時期です。 フェッチ API - Web API | MDNフェッチ API は(ネットワーク越しの通信を含む)リソース取得のためのインターフェイスを提供しています。 XMLHttpRequest と似たものではありますが、より強力で柔軟な操作が可能です。https://developer.mozilla.org/ja/docs/Web/API/Fetch_API

Fetch API は XHR の精神を引き継いでいるので、どちら を使っても同じことですが、 厳密にはこれらは別物なので混同しないようにしましょう。

同一生成元ポリシー(同一オリジンポリシー)

さて、さっきからあたりまえのように使っているクロスオリジンとは、同一生成元ポリシーを満たしていないことなのですが これ自体が少しわかりにくいものなので説明しておきます。

同一生成元ポリシーはオリジン、つまり「スキーム」「ホスト」「ポート」がすべて一致することです。

平たく言えば 「対象URLのパスより前の部分」 が「現在アクセスしているサイト」と一致していることです。 このサイトであれば https://note.crohaco.net/ と前方一致するURLが同一生成元ポリシーを満たします。(アレ?簡単になってないかも)

もちろんこのサイトの記事はすべて 制約を満たしています🌝

スキーム
  • https://
ホスト
  • note.crohaco.net
ポート
  • :443
  • (スキームが http で ポートが 80 もしくは スキームが https で ポートが 443 なら省略可(ブラウザによる))

しかし、例えば http://note.crohaco.net は一見アクセスしても良さそうですが、 スキーム(http)とポート(80)が違うので 同一生成元ポリシーは満たされません🌚

上では非同期通信のセキュリティとして同一生成元ポリシーがあると言いましたが localStorage(sessionStorage)iframe もこの制限が適用されます。

このためサイトのドメインが変わったりHTTPS移行したりすれば、localStorageに保存していたデータも読み出せなくなります。 関係ないけどHTTPS移行するとはてブも死にます(経験談)

これがWebアプリであれば全ユーザの認証が一旦すべて切断されるという自体にもなりえます🙀

オリジン間リソース共有 (CORS)

上述した 同一生成元ポリシー を超えて クロスオリジン通信を行うことを オリジン間リソース共有(以下 CORS) といいます👻

先程ちらっと言いましたが、通信の可否を左右するのが 以下のレスポンスヘッダです。

info
  • アクセス元のオリジンが完全に一致している(同一オリジンの)場合、これらのヘッダの有無や内容にかかわらず通信は成功します。
  • 以降の話はすべてクロスオリジンとの通信と考えてください。

これらのヘッダの値をもとに ブラウザが 通信の可否を判断します。

一般的にサーバ側は CORS に関しては設定によって決められたレスポンスヘッダを返すだけで、 通信内容をもとに失敗させたりはしません。(中にはやってるところもあるかもしれませんが)

ブラウザは Access-Control-Allow- で始まっているレスポンスヘッダで満たしていないものがあると通信エラーとして処理します。 その中でも特に重要なものが Access-Control-Allow-Origin です。(まぁ全部重要ですけどね)

Access-Control-Allow-Origin には 通信を許可する 送信元オリジン を指定し、 送信元のオリジンがこれに該当するようであれば、クロスオリジンであってもブラウザは通信を許可するというわけです🐤

指定可能な値は以下です。

  • * (すべて許可)
  • オリジン
    • 複数の場合は ' ' (スペース)区切りで指定する
    • *.example.com のようなサブドメイン指定は不可能。
      • Nginx では サブドメイン等の条件を判定して、アクセス元のオリジン ($http_origin) をそのまま返却するという方法がよく取られるようです。
  • null (使わない💩)

Preflight

さて、 Access-Control-Allow 系のレスポンスヘッダを満たさないとブラウザは 通信を失敗させるわけですが、 通信の失敗と一口に言っても「リクエスト送信前」と「レスポンス受信後」の2パターンの失敗があります🤘

ん?レスポンスヘッダを見るのにリクエスト送信前に失敗っておかしくないか?って思いましたね。

本来のリクエスト送信前に送信するリクエストを プリフライトリクエスト(以降単にプリフライト)といいます。

プリフライトは 「本当にリクエスト送っていいの?」っていうのを確かめるためのブラウザのセキュリティ機構です👮‍

出典: プリフライトリクエスト - オリジン間リソース共有 (CORS) - HTTP | MDN

「単純リクエスト」 (前述) とは異なり、「プリフライト」リクエストは始めに OPTIONS メソッドによるリクエストを他のドメインにあるリソースに向けて送り、実際のリクエストを送信しても安全かどうかを確かめます。

この2パターンを図にしてみるとこんなかんじです。

(ブラウザがアクセス中のドメインを a.example.com サーバのドメインを b.example.com として御覧ください)

  • リクエスト前で失敗
  • レスポンス後で失敗
  • sequenceDiagram participant browser participant server browser->>server: OPTIONS / (プリフライト) server-->>browser: Access-Control-Origin: server を返す browser-xserver: PATCH / Note right of server: Access-Control-Originと送信元ドメインが<br />一致しないため本来のリクエストを遮断する
  • sequenceDiagram participant browser participant server browser-xserver: OPTIONS / (プリフライト) が飛ばなかった browser->>server: GET / Note right of server: 本来送りたいリクエスト server-->>browser: Access-Control-Alllow-Originを含まないレスポンスを返す

Access-Control-Max-Age ヘッダがある場合、 ブラウザにプリフライトの結果をキャッシュすることができます。 単位は 秒(s) です。

キャッシュが有効な間、プリフライトによる確認なしに 通信が許可されます。 キャッシュはパス毎に保存され、HTTPメソッドの違いは無視されます。

info
  • Access-Control-* のヘッダは 概ね プリフライトによる OPTIONS メソッドリクエストのときだけ返すのが一般的ですが、 それ以外の場合もブラウザはそのレスポンスヘッダをもとに通信可否をチェックしてくれます。
  • ただし、キャッシュされるのはプリフライトとして送信されたリクエストが 成功 した場合だけであり、 プリフライト以外の場合は、例え OPTIONSメソッド であってもキャッシュはされませんし、失敗した場合もキャッシュされません。
  • 先程、結果をキャッシュと書きましたが、これは単なる真偽値ではなく 一部のレスポンスヘッダも含めて保持しています。 (Preflight Result Cache)
  • そのため、プリフライトで成功したキャッシュを持っている状態であっても、 サーバが許可していないメソッド(やヘッダ)で同じURLへアクセスしてみるとキャッシュが持つ 許可メソッド や 許可ヘッダ との照合に失敗し、プリフライトが飛びます。

でも、なぜこんなまどろっこしいことをするのでしょう? レスポンスの時点で失敗させればそれでいいじゃん。と。

Cross Site Request Forgery

この記事を読んでいる方なら CSRF という言葉を聞いたことがあると思います。

一般的には Cookie や Basic認証など、ドメインに紐付いて自動送信されるユーザ情報を用いて なんらかの完了処理を行わせる受動型の攻撃です。

CSRF は 実行させたいリクエストを 対象ユーザから 送らせることで攻撃を実現します👿

特に重要なのは 攻撃リクエストが届いてしまった時点(往路)でサーバ側での処理が実行されてしまう という点です。 レスポンスをブラウザがエラーとして処分した(復路)としても既に攻撃が終わっている可能性があるのです。

こういった事情があるので、リクエストの送信を制限することに意味があります。

info
  • 極論を言うとユーザ情報が紐付いてなくても掲示板などで殺人予告などの脅迫行為をすれば IP をたどって逮捕される可能性があるので、 一概にユーザ情報が紐付いている場合だけが危険とは断言できません。
  • さすがに、ノーガードのサイトへ書き込みで無差別に誤認逮捕させられることはないと思いたいですが、 近頃の状況を見るとなんとも言えないので、君子危うきには近寄らずということで頑張ってやっていこうな。
  • でも、第三者からの攻撃をクライアント(ユーザ)側だけで抑えるのは難しいことです。
  • Webアプリのセキュリティホールを放置することは いろんな形でユーザに迷惑をかける事につながるとちゃんと心に刻んでおきましょう😇

単純リクエスト

先程、失敗タイミングとして「リクエスト送信前」と「レスポンス受信後」と言いましたが、 ブラウザはどのようにこれらを使い分けているのでしょうか。

実はプリフライトはリクエストが「単純」でないときにのみ送信されます。

なんだよそのゆるふわな定義は!😡と思ってしまいそうですが、実は「単純」にもちゃんとした定義があります。

具体的には以下をすべて満たせば「単純である」といえます。

裏を返すと、これを一つでも満たさなければプリフライトは自動的に送出されます

しくじり先生

プリフライト機構を過信(誤解)して、CSRFトークンを使わなくてもCSRF攻撃を予防できると考えていると、 上記の仕様により制約を回避してCSRF攻撃を受けてしまう可能性があります😫

以前 Cookie で認証を行っているSPAサイトのセキュリティ診断をしました。 このサイトでは Cookie で認証情報を送信しているにもかかわらず、CSRFトークンを利用していませんでした (この時点でだいぶアウトな感じですが

リクエストの形式が JSON、つまり Content-Type が application/json になるため、プリフライトが自動的に飛びます。 プリフライトが強制されていれば外部起点の非同期リクエストが送られないので、CSRFも発生しないと考えたのですね🤖

なんとなく理屈としては正しそうに聞こえます。しかし抜け穴がありました。 サーバ(アプリ)側で Content-Type として application/x-www-form-urlencoded を使うことが許可されていたのです。

この Content-Type に合わせて a=1&b=2 のようなパラメータ形式でPOSTでリクエストさせることでプリフライトを回避して攻撃が成功してしまいました。 (実はformタグを使えば POST までなら送れるので、プリフライトを回避しなくても成功します)

このサイトでは攻撃用ページ(別ドメイン)から送られたリクエスト に対するレスポンスは Access-Control-Origin ヘッダを含まなかったためブラウザが破棄して表示上は通信エラーですが、攻撃はやはり成功していました。

sequenceDiagram participant browser participant server browser-xserver: OPTIONS / (プリフライト) が飛ばなかった browser->>server: POST / Note right of server: 攻撃リクエスト server-->>browser: レスポンス Note left of browser: レスポンスはブラウザによって破棄されるが<br />この時点で攻撃は成功
info
  • そんなのこのサイトだけの話だろって思うかもしれませんが、 実はこのサイトではDjangoRESTFramework (略記:DRF) を使っていました。 PythonでSPAアプリを作ろうとするとわりとよく使われるフレームワークなのです。
  • DRFではビュー単位、またはサイト全体に対して複数のパーサーを設定できますが、 デフォルトの設定では application/x-www-form-urlencoded 形式のパラメータが許可されているので、 同じような問題を含むサイトは潜在的にわずかながら存在するのではないかと思っています🙈
  • ([Django REST Framework] View の使い方をまとめてみた - くろのて)
  • Parser を限定することは実はセキュリティ対策の一環と言えるのですね
  • 当時診断していた私は DRF をよく知らなかったのでこの問題を見逃しそうになりましたが、 てるひこマンの調査協力もありなんとか発見できました。

おわりに

非同期通信は今日のWeb技術を支える大切な技術です。

CORSをきちんと理解して、便利で安全なWebを一緒に作っていきましょう🐍

今回検証で使ったサーバープログラムは bottle で以下のように作りました。

bottle をインストールして python3 server.py とすると localhost:8080 上でサーバが動くので、ブラウザのコンソールから fetch('http://localhost:8080/', {method: 'get'}) みたいにいろいろ試せます。