@aki_yok さんの DRF本 レビューをしていて Preflight について記述があったんですが、 自分が しっかり理解できていないような気がしたので CORS を絡めて簡単にまとめてみました。
昔話 👹
はるか昔 🐉 XMLHttpRequest (以降 XHR という) により私達は非同期通信を手に入れました。
この便利機能は その強力さ故に 同一生成元ポリシー というセキュリティ機構で守られていました。
実は XHR には 規格みたいなものがあって、 XHR Level2 と呼ばれる新規格がでるまで、 初期の XHR では クロスオリジン通信が行えませんでした 🙅
この間、クロスオリジンの通信として用いられていたのが JSONP と呼ばれる JavaScriptの読み込みをうまく利用した非同期通信です。
これは厳密には JSON でもなければ XHR でもなく、 <script>
タグを動的に挿入することで
データを JavaScript ファイルとして読み込み、
呼び出し側が指定したコールバック関数を実行することにより読み込んだデータを 変数に格納するという、いわばハックです😎
一時期 WebAPI に &callback=_
みたいなパラメータ指定してたじゃないですか。あれがJSONPです。
あの頃はみんな jQueryで使ってたんじゃないかな(どうでもいい)
とはいえ、JSONデータではなく、JavaScriptのコードが問答無用で実行されてしまうので信頼できるサーバとしか通信できないなど セキュリティ的にはあまりよろしくありませんでした
備考
JSONP でデータをやり取りするためには サーバ、クライアント両方の対応が必要で、 現在ではこの技術自体が絶滅寸前なのでクライアント側で攻撃を受けることはほぼないでしょう。
サービスを受けることもできないと思いますが..
この状況を解消すべく登場したのが XHR Level2 です。 こいつを使うことで サーバ側が許可したオリジンからのみ リクエストを受け付けることが可能となりました。やったー👼 (どうやってやるかは後述)
その後の2014年に Level2 は Level1 と統合される形で消滅しました 💥
そして、今では Fetch API と呼ばれる新規格が登場し、 XHR はもう引退の時期です。
Fetch API は XHR の精神を引き継いでいるので、どちら を使っても同じことですが、 厳密にはこれらは別物なので混同しないようにしましょう。
同一生成元ポリシー
同一生成元ポリシーは オリジン、つまり「スキーム」「ホスト」「ポート」がすべて一致することです。
平たく言えば 「対象URLのパスより前の部分」 が 「現在アクセスしているサイト」と一致していることです。 このサイトであれば http://note.crohaco.net/ と前方一致するURLが同一生成元ポリシーを満たします。
備考
このブログは 2019/08 にHTTPSでのアクセスに対応しました(HTTPでもアクセス可能です)
もしかすると今この記事を読んでいる方は HTTPS でアクセスしているかもしれませんが、 この記事はHTTPで接続されている前提で書かれているため、各々で読み替えてください。
もちろんこのサイトの記事はすべて 制約を満たしています🌝 (アレ?簡単になってないかも)
- スキーム
-
http://
- ホスト
-
note.crohaco.net
- ポート
-
:80
(スキームが http で ポートが 80, もしくは スキームが https で ポートが 443 なら省略可)
しかし、例えば https://note.crohaco.net は一見アクセスしても良さそうですが、 スキーム(https)とポート(443)が違うので 同一生成元ポリシーは 満たされません🌚
上では非同期通信のセキュリティとして同一生成元ポリシーがあると言いましたが localStorage(sessionStorage) や iframe もこの制限が適用されます。
今みなさんがご覧になっているこのサイトは ご覧の通り http で運用していて、 記事毎の読了率(どこまで読んだか)を localStorage と呼ばれるブラウザの内部領域に 保存しています。
もし今後、サイトのドメインが変わったり https に移行するようなことがあれば、この読了率がリセットされます
これが Webアプリであれば全ユーザの認証が一旦すべて切断されるという自体にもなりえます🙀
備考
読了率は手元のブラウザに保存されているだけで収集はしていませんので安心してください😇
オリジン間リソース共有 (CORS)
上記の 同一生成元ポリシー を超えて クロスオリジン通信を行うことを オリジン間リソース共有(以下 CORS) といいます👻
先程ちらっと言いましたが、通信の可否を左右するのが 以下のレスポンスのヘッダです。
備考
アクセス元のオリジンが完全に一致している(同一オリジンの)場合、これらのヘッダの有無や内容にかかわらず通信は成功します。
以降の話はすべてクロスオリジンとの通信と考えてください。
これらのヘッダの値をもとに ブラウザが 通信の可否を判断します。
一般的にサーバ側は CORS に関しては設定によって決められたレスポンスヘッダを返すだけで、 通信内容を判断し失敗させたりはしません。(中にはやってるところもあるかもしれませんが)
ブラウザが判断する上で肝となるのが Access-Control-Allow-Origin です。 その他のものは必要に応じて後述します。
Access-Control-Allow-Origin には 通信を許可する 送信元オリジン を指定し、 送信元のオリジンがこれに該当するようであれば、クロスオリジンであってもブラウザは通信を許可するというわけです。
指定可能な値は以下です。
* (すべて許可)
-
オリジン
複数の場合は ' ' (スペース)区切りで指定する
-
*.example.com
のようなサブドメイン指定は不可能。-
Nginx では サブドメイン等の条件を判定して、 アクセス元のオリジン (
$http_origin
) をそのまま返却するという方法がよく取られるようです。
-
null (使わない💩)
Preflight
レスポンスヘッダの オリジン とリクエスト送信元オリジンが一致しないと ブラウザは 通信を失敗させます。
通信の失敗と一口に言っても「リクエスト送信前」と「レスポンス受信後」の2パターンの失敗があります。
ん?レスポンスヘッダを見るのにリクエスト送信前に失敗っておかしくないか?って思いましたね
本来のリクエスト送信前に送信するリクエストを プリフライトリクエスト(以降単にプリフライト)といいます。
プリフライトは 「本当にリクエスト送っていいの?」っていうのを確かめるためのブラウザのセキュリティ機構です👮
出典: プリフライトリクエスト - オリジン間リソース共有 (CORS) - HTTP | MDN
「単純リクエスト」 (前述) とは異なり、「プリフライト」リクエストは始めに OPTIONS メソッドによるリクエストを他のドメインにあるリソースに向けて送り、実際のリクエストを送信しても安全かどうかを確かめます。
リクエスト前で失敗 |
レスポンス後で失敗 |
---|---|
Access-Control-Max-Age ヘッダがある場合、 ブラウザにプリフライトの結果をキャッシュすることができます。 単位は 秒(s) です。
キャッシュが有効な間、プリフライトによる確認なしに 通信が許可されます。 キャッシュはパス毎に保存され、メソッドの違いは無視されます。
備考
Access-Control-*
のヘッダは 概ね プリフライトによる
OPTIONS メソッドリクエストのときだけ返すのが一般的ですが、
それ以外の場合もブラウザはそのレスポンスヘッダをもとに通信可否をチェックしてくれます。
ただし、キャッシュされるのはプリフライトとして送信されたリクエストが 成功 した場合だけであり、 プリフライト以外の場合は、例え OPTIONSメソッド であってもキャッシュはされませんし、失敗した場合もキャッシュされません。
先程、結果をキャッシュと書きましたが、これは単なる真偽値ではなく 一部のレスポンスヘッダも含めて保持しています。 (Preflight Result Cache)
そのため、プリフライトで成功したキャッシュを持っている状態であっても、 サーバが許可していないメソッド(やヘッダ)で同じURLへアクセスしてみると キャッシュが持つ 許可メソッド や 許可ヘッダ との照合に失敗し、プリフライトが飛びます。
でも、なぜこんなまどろっこしいことをするのでしょう? レスポンスの時点で失敗させればそれでいいじゃん。と。
Cross Site Request Forgery
この記事を読んでいる方なら CSRF という言葉を聞いたことがあると思います。
一般的には Cookie や Basic 認証など、ドメインに紐付いて自動送信されるユーザ情報を用いて なんらかの完了処理を行わせる受動型の攻撃です。
CSRF は 実行させたいリクエストを 対象ユーザから 送らせることで攻撃を実現します👿
特に重要なのは 攻撃リクエストが届いてしまった時点(往路)でサーバ側での処理が実行されてしまう という点です。 レスポンスをブラウザがエラーとして処分した(復路)としても既に攻撃が終わっている可能性があるのです。
こういった事情があるので、リクエストの送信を制限することに意味があります。
備考
極論を言うとユーザ情報が紐付いてなくても 掲示板などで殺人予告などの脅迫行為をすれば IP をたどって逮捕される可能性があるので、 一概にユーザ情報が紐付いている場合だけが危険とは断言できません。
さすがに、ノーガードのサイトへ書き込みで無差別に誤認逮捕させられることはないと思いたいですが、 近頃の状況を見るとなんとも言えないので、君子危うきには近寄らずということで頑張ってやっていこうな。
でも、第三者からの攻撃をクライアント(ユーザ)側だけで抑えるのは難しいことです。
Webアプリのセキュリティホールを放置することは いろんな形でユーザに迷惑をかける事につながるとちゃんと心に刻んでおきましょう😇
単純リクエスト
実はプリフライトはリクエストが「単純」でないときにのみ送信されます。
なんだよそのゆるふわな定義は!😡と思ってしまいそうですが実はちゃんとした定義があります
具体的には以下をすべて満たせば「単純である」といえます。
HTTPメソッドが HEAD, GET, POST のいずれかである
-
特定のHTTPリクエストヘッダが含まれない
-
詳細は以下を参照 (手元で検証したけど書いてあるとおりにならなかったのであんまり自信がない)
-
-
Content-Type が 以下の場合
application/x-www-form-urlencoded
multipart/form-data
text/plain
裏を返すと、これを一つでも満たさなければプリフライトは自動的に送出されます
しくじり先生
プリフライト機構を過信(誤解)して、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 ヘッダを含まなかったため ブラウザが破棄して表示上は通信エラーですが、攻撃はやはり成功していました。
備考
そんなのこのサイトだけの話だろって思うかもしれませんが、実はこのサイトでは DjangoRESTFramework (略記:DRF) を使っていました。 PythonでSPAアプリを作ろうとするとわりとよく使われるフレームワークなのです。
DRFでは ビュー単位、またはサイト全体に対して複数のパーサーを設定できますが、 デフォルトの設定では application/x-www-form-urlencoded 形式のパラメータが許可されているので、 同じような問題を含むサイトは潜在的にわずかながら存在するのではないかと思っています🙈
([Django REST Framework] View の使い方をまとめてみた - くろのて)
Parser を限定することは実はセキュリティ対策の一環と言えるのですね
当時診断していた私は DRF をよく知らなかったのでこの問題を見逃しそうになりましたが、 てるひこマンの調査協力もありなんとか発見できました。
おわりに
非同期通信は今日のWeb技術を支える大切な技術です。
CORSをきちんと理解して、便利で安全なWebを一緒に作っていきましょう🐍
今回検証で使ったサーバープログラムは bottle で以下のように作りました。
bottle==0.12.16
from bottle import hook, response, route, run, app
@hook('after_request')
def enable_cors():
response.headers.update({
# 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Origin': 'http://note.crohaco.net',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'accept, accept-language, content-language, content-type',
'Access-Control-Max-Age': 10,
})
@route('<any:path>', method='OPTIONS')
def response_for_options(**kwargs):
return {}
@route('/', method='GET')
def get():
return ''
@route('/', method='PUT')
def put():
return ''
@route('/', method='DELETE')
def delete():
return ''
@route('/a', method='PUT')
def put2():
return ''
run()
bottle をインストールして python3 server.py
とすると
localhost:8080 上でサーバが動くので、ブラウザのコンソールから
fetch('http://localhost:8080/', {method: 'get'})
みたいにいろいろ試せます。