30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

SPA の CSRF 対策や CORS について検証する

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ではなくXMLHttpRequestsfetchを用いて非同期通信で叩くことになるのが一般的だと思うが、異なるオリジンに対して非同期通信を行うときは必ず、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で公開してある。

github.com

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とは無関係に、GETPOSTは許可されているようだった。DELETEは明示的に許可していないとエラーになる。ブラウザによって異なるかもしれない。ここらへんの挙動は詳しく調べていないので分からない。

事前に必ずOPTIONSメソッドによるリクエストが発生する、というのが重要であり、これにより、CSRF攻撃のリクエストはAPIサーバーに届かなくなる。許可していないオリジンからのアクセスということで、その前のプリフライトリクエストで通信が終了してしまうから。

プリフライトリクエストを発生させる方法はいくつかあるが、リクエストに独自ヘッダをつければ、必ず発生する。

そのため、APIサーバーへのリクエストには何らかの独自ヘッダの付与を義務付け、そのヘッダがないリクエストは全て拒否することで、CSRF対策になる。

独自ヘッダがついていなければそれが理由でエラーになる。
ついていた場合は、プリフライトリクエストが発生するため、許可していないオリジンからのリクエストは事前に失敗する。

これも、先程のリポジトリで再現できる。

  1. 最初に開いていたマイページの画面上部のリンクから、「CSRF対策が行われているAPIを利用しているマイページ」に移動する
  2. 先程の要領でログインし、「購入数」を適当に増やしておく
  3. 画面下部のリンクから「罠ページ」に遷移する
  4. 「throw preflight cross-origin request」を押下すると、独自ヘッダを付与したCSRF攻撃を仕掛ける
  5. 「throw simple cross-origin request」を押下すると、独自ヘッダを付与していないCSRF攻撃を仕掛ける
  6. 45のいずれの操作を行っても攻撃に失敗し、「購入数」に変化がないことを確認する

同期通信による CSRF への対策

ここまで書いてきたのは、CORSを利用したセキュリティ対策。
しかし、formによる送信などの同期通信はCORSの対象外となってしまい、オリジンによる制限などを利用できない。
そのため、formを使ってCSRF攻撃を行われた場合は、無防備になってしまう。

「CSRF対策が十分に行われていないAPIへの攻撃ページ」の「お得な情報はこちらから」がまさにそれで、このリンクをログイン済みのユーザーにクリックさせて、CSRF攻撃を行う。
しかもこのケースではiframeを使っているため攻撃後の画面のリロードが行われず、ユーザーは気付きにくい。

これを防ぐためには、何でもいいので独自ヘッダを義務付けるようにすればよい。
独自ヘッダを付与するのはformなどでは不可能で、XMLHttpRequestsfetchを使わなければならない。
そのため、formなどの同期通信によるリクエストを防ぐことが出来る。

つまり、独自ヘッダを義務付けることで、プリフライトリクエストを発生させるだけでなく、同期通信によるCSRF攻撃を防ぐ効果も生まれるのである。

この仕組みにより、「CSRF対策が行われているAPIへの攻撃ページ」の「お得な情報はこちらから」による攻撃は失敗する。

ただ、XMLHttpRequestsfetch以外では独自ヘッダを付与できないことを明示している資料は見つからなかった。誰か知っていたら教えてください。

認証情報をクライアントのどこに保存するか

ここまでの説明で、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から修正されている。

まとめ

  1. リクエストヘッダに何らかの独自ヘッダを加えることを義務付け、それがあるかをまずチェックして、存在しなければそのリクエストは不正なアクセスだと見做す
  2. Access-Control-Allow-*でどのようなオリジン間通信を許可するのかを正しく設定する

この2つの対策を行うことで、SPAのCSRF対策としては十分だと思う。
ただ、既に述べたように、XSSなどでAPIサーバーと同じオリジンからCSRFを行われた場合は、この限りではない。

参考資料