2021/4/23 追記
Twitter にて指摘を頂いたので追記。
詳細は当該ツイートを読んで頂きたいが、プリフライトリクエストを CSRF 対策として用いるのは適切ではないという内容。
この記事に書いた仕組みや挙動そのものが間違っているわけではないのだが、プリフライトリクエストはそもそもセキュリティのための機能ではない。
そして、詳しくは記事の続きを読んでほしいが、プリフライトリクエストが発生するということは、HTTP メッセージのやり取りが 1 回増えるということなので、パフォーマンス上、望ましくない。
代替案がないならともかく、リクエストのオリジンをチェックすれば対応できるのだから、敢えてプリフライトリクエストを利用する必要はない。素朴に書けば以下のようになるだろうか。
const ALLOW_ORIGIN = `http://localhost:${constants.SPA_PORT_NUMBER}`; if (req.headers.origin !== ALLOW_ORIGIN) { apiResponse(res, 400, {result: "error"}); return; }
追記終わり
SPAのCSRF対策について調べる機会があったので、まとめておく。
CORSの挙動を中心に、実際にコードを書いて検証していった。
間違ったことは書いていないと思うけど、利用は自己責任でお願いします。
CSRF とは
CSRFそのものについての詳しい記事はネット上にいくらでもあるので、そちらを参照。
一例:第3回 Webセキュリティのおさらい その3 CSRF・オープンリダイレクト・クリックジャッキング:JavaScriptセキュリティの基礎知識|gihyo.jp … 技術評論社
一言で言ってしまえば、サービスの利用者を罠サイトにアクセスさせて、APIサーバーに対する意図しないリクエストを送信させることである。
そのユーザーがサービスにログイン済みであり、セッションIDをクッキーに保存していた場合、その情報も一緒に送信される。そのため、そのリクエストは「認証済みのユーザーからのリクエスト」であるため、ユーザーが意図していなかったとしても、そのリクエストは正当なリクエストとして処理されてしまう。
これにより、退会や決済、SNSへの不適切な投稿などのセンシティブな操作が、ユーザーが意図しない形で行われてしまう。
CSRFトークン
CSRF対策としてよく利用されるのが、トークンを用いた対策。だがこれは、SPAでは難しい。
SPAをレンダリングするサーバーとAPIサーバーが別々の場合、どうやってトークンを受け渡しするのかが自分には分からなかった。
例えば Rails アプリの場合は、Rails が返す html のなかにトークンを仕込んでおき、それをdocument.getElementsByName('csrf-token')[0].content
のような形で拾って、それをAPIへのリクエストの際に送信すればよい。
だが一般的なSPAではフロントエンドとバックエンドが独立して存在していると思うので、この方法は使えないと思う。
CORS(Cross-Origin Resource Sharing)
SPAでAPIを叩く場合は、form
ではなくXMLHttpRequests
やfetch
を用いて非同期通信で叩くことになるのが一般的だと思うが、異なるオリジンに対して非同期通信を行うときは必ず、CORSを用いた通信になる。
このCORSの仕組みが、「トークンを使わない場合のCSRF対策」の根幹になる。
本稿では、APIサーバーと同じオリジンからのリクエストについては、「信頼できるリクエスト」と見做すことにする。
XSS脆弱性でもない限り、CSRFは異なるオリジンから行われるので、この前提はおかしくないと思う。
そして当然、XSS対策はCSRF対策とはまた別にそれ自体が行われるべきなので、CSRF対策を扱う本稿ではスコープ外となる。
そのため、CSRFによるリクエストは、次の2種類になる。
- 異なるオリジン間の非同期通信
- 異なるオリジン間の同期通信
上述したように、「異なるオリジン間の非同期通信」は必ずCORSを利用した通信になるので、CORSの仕組みを利用してCSRF対策を行っていく。
form
などの、同期通信によるリクエストへの対策については、後述する。
Access-Control-Allow-Origin の落とし穴
CORSではAccess-Control-Allow-Origin
というレスポンスヘッダを使って、アクセスを許可するオリジンをサーバー側が指定する。
これを使うことで、信頼できるオリジン以外からのリクエスト、つまり、CSRF攻撃を仕掛けてくるオリジンからのリクエストを制限することが出来る。
しかしAccess-Control-Allow-Origin
さえ使っていれば安全かといえば、そうではない。落とし穴がある。
CORSは「Cross-Origin Resource Sharing」の名の通り、あくまでもリソースの読み込みを制御するというだけで、たとえ許可していないオリジンからであったとしてもサーバーへのアクセス自体は発生するのである。
そのため、サーバーサイドの実装によってはCSRF攻撃が成立してしまう。
実際に再現してみる。
本稿で紹介する事象は全てコードで検証しており、そのコードはGitHubで公開してある。
10.9.0
以上のNode.jsと1.12.3
以上のYarnがあれば動く。
$ yarn start
で、Vueで作られた「マイページ」が開く。
早速、先程の脆弱性を再現してみる。
まずは説明文に従ってログインする。その後、Buy
ボタンを何回か押して、購入する。ブラウザをリロードしても「購入数」に変化はないことを確認する。
次に、ページ下部のリンクで「罠ページ」に遷移してからthrow simple cross-origin request
と書かれているボタンを押す。
すると、許可していないオリジンからの非同期通信なので、この通信はレスポンスを得られない。
だが、APIへのアクセス自体は発生してしまっており、マイページに戻ってリロードすると、「購入数」が0
になっていることを確認できる。
つまり、Access-Control-Allow-Origin
を指定しただけではCSRF対策としては不十分だということが分かる。
プリフライトリクエスト
上記のような攻撃を防ぐために、プリフライトリクエストを利用する。
プリフライトリクエストとは、CORSの仕組みの一部で、特定の条件においてブラウザが自動的に発行するリクエストのこと。IE11を含め、メジャーなブラウザは全て対応している。
具体的には、本来のリクエストを送ることが許可されているかを確認するためにまず、OPTIONS
メソッドでリクエストを投げ、許可されていた場合にのみ、続けて本来のリクエストを投げる。
どのようなリクエストを許可するかは、Access-Control-Allow-*
という形式のレスポンスヘッダを使って条件を指定する。Access-Control-Allow-Origin
でオリジンを指定したように、許可するリクエストヘッダやHTTPメソッドを指定できる。
ただ、Access-Control-Allow-Methods
とは無関係に、GET
やPOST
は許可されているようだった。DELETE
は明示的に許可していないとエラーになる。ブラウザによって異なるかもしれない。ここらへんの挙動は詳しく調べていないので分からない。
事前に必ずOPTIONS
メソッドによるリクエストが発生する、というのが重要であり、これにより、CSRF攻撃のリクエストはAPIサーバーに届かなくなる。許可していないオリジンからのアクセスということで、その前のプリフライトリクエストで通信が終了してしまうから。
プリフライトリクエストを発生させる方法はいくつかあるが、リクエストに独自ヘッダをつければ、必ず発生する。
そのため、APIサーバーへのリクエストには何らかの独自ヘッダの付与を義務付け、そのヘッダがないリクエストは全て拒否することで、CSRF対策になる。
独自ヘッダがついていなければそれが理由でエラーになる。
ついていた場合は、プリフライトリクエストが発生するため、許可していないオリジンからのリクエストは事前に失敗する。
これも、先程のリポジトリで再現できる。
- 最初に開いていたマイページの画面上部のリンクから、「CSRF対策が行われているAPIを利用しているマイページ」に移動する
- 先程の要領でログインし、「購入数」を適当に増やしておく
- 画面下部のリンクから「罠ページ」に遷移する
- 「throw preflight cross-origin request」を押下すると、独自ヘッダを付与したCSRF攻撃を仕掛ける
- 「throw simple cross-origin request」を押下すると、独自ヘッダを付与していないCSRF攻撃を仕掛ける
4
と5
のいずれの操作を行っても攻撃に失敗し、「購入数」に変化がないことを確認する
同期通信による CSRF への対策
ここまで書いてきたのは、CORSを利用したセキュリティ対策。
しかし、form
による送信などの同期通信はCORSの対象外となってしまい、オリジンによる制限などを利用できない。
そのため、form
を使ってCSRF攻撃を行われた場合は、無防備になってしまう。
「CSRF対策が十分に行われていないAPIへの攻撃ページ」の「お得な情報はこちらから」がまさにそれで、このリンクをログイン済みのユーザーにクリックさせて、CSRF攻撃を行う。
しかもこのケースではiframe
を使っているため攻撃後の画面のリロードが行われず、ユーザーは気付きにくい。
これを防ぐためには、何でもいいので独自ヘッダを義務付けるようにすればよい。
独自ヘッダを付与するのはform
などでは不可能で、XMLHttpRequests
やfetch
を使わなければならない。
そのため、form
などの同期通信によるリクエストを防ぐことが出来る。
つまり、独自ヘッダを義務付けることで、プリフライトリクエストを発生させるだけでなく、同期通信によるCSRF攻撃を防ぐ効果も生まれるのである。
この仕組みにより、「CSRF対策が行われているAPIへの攻撃ページ」の「お得な情報はこちらから」による攻撃は失敗する。
ただ、XMLHttpRequests
やfetch
以外では独自ヘッダを付与できないことを明示している資料は見つからなかった。誰か知っていたら教えてください。
認証情報をクライアントのどこに保存するか
ここまでの説明で、CSRF対策としては十分だと認識している。
ただ、認証情報をどこに保存するのか、という問題にも軽く触れておく。
ここでいう認証情報とは、セッションIDやトークンなどの、そのユーザーであることを証明するための情報。これがあることで、サーバー側に認証・認可してもらえる。
保存場所の選択肢は2つ。まずはCookie
。かつてはこれしか選択肢はなかった。だが現在では、Web Storage
という仕組みもある。
Cookie
の利点は、HttpOnly
属性を有効にすることで、JavaScriptから読み込まれることを禁止できること。これにより、XSSの脆弱性があったとしても、そこからCookie
を読み込まれる心配がない。
また、認証に使うことが一般的であり、そのためのノウハウやライブラリが豊富にある、というのも利点だと思う。
欠点は、リクエストを送る度に自動的にCookie
の情報も一緒に送信されてしまうこと。例えばhttp://example.com
から渡されたCookie
は、http://example.com
にリクエストを送る度に自動的に送信されてしまう。多くのCSRFは、この仕組みを悪用したものだと言える。
Web Storage
の利点は、Cookie
と違い、明示しない限りサーバーに送信されることはないということ。
そして、同一生成元ポリシーに基づきWeb Storage
はオリジン毎に管理されているため、「罠ページ」で、Web Storage
を読み込まれてしまうことはない。
この2つの特徴により、認証情報をWeb Storage
に保存しておけば、それだけでCSRFを防げるはず。
欠点は、同じオリジンであれば簡単にJavaScriptで読み込めるため、XSS脆弱性があった場合はWeb Storage
の中身を読み取られてしまう。
また、Cookie
に比べれば、認証に用いることがまだ一般的ではなく、サーバーサイドの実装で工数が増えてしまうかもしれない。
さらに、比較的新しい技術なので、一部のブラウザでは上手く機能しない恐れがある。例えば、v10
までのSafariは、プライベートブラウジングモードではWeb Storage
が上手く機能しなかった。Appleの開発者のツイートによれば、v11
から修正されている。
まとめ
- リクエストヘッダに何らかの独自ヘッダを加えることを義務付け、それがあるかをまずチェックして、存在しなければそのリクエストは不正なアクセスだと見做す
Access-Control-Allow-*
でどのようなオリジン間通信を許可するのかを正しく設定する
この2つの対策を行うことで、SPAのCSRF対策としては十分だと思う。
ただ、既に述べたように、XSSなどでAPIサーバーと同じオリジンからCSRFを行われた場合は、この限りではない。