現代的なCSRF対策
先に結論
副作用を伴わないリクエスト時のread用トークン、DB書き込みなどを行えるwrite用のトークンに分ける
read用のトークンをcookieに詰めてSSR/RSCに利用する
一応いずれデフォルトになり得る SameSite=Lax にしておくと良さそう
write用のトークンはXHRのみで利用してAuthorizationヘッダーに入れる
サーバー側でwrite用トークンが検証できた時のみDBなどへの書き込みなど副作用を伴う操作を許容する
間違ってる点がありましたらtwitterで言及してもらえるととても有り難いです🙏 この案に至ったシチュエーション
認可はステートレスに行いたいのでjwtを採用
スケーラビリティを考慮
疑問? ECDSA(prime256v1)はverifyのコストが高いがKVSに問い合わせるのより本当に速いのか?スケーラビリティというのは速度面よりも設計面の単純さという点に重きをおいている?
SSR/RSCでuser_idを利用したい
Cookieにトークンを入れる必要がある
→CSRF対策が必要になる
SSR/RSCするときはread以外は必要なさそう
Cookieに載せたトークンに認可する操作では副作用を生まないルールにする
リクエストctxでDBへのread権限のみを与えるように制約を入れる
XHR経由のリクエスト
Authorizationヘッダーを原則必須化して、Cookieに入っているトークンによる認可を行わない
(一部のauthorizeせずに利用可能な副作用などは例外になってくるが、reCAPTCHAを通す or unsafeな副作用であることを明示しつつ記述するような設計にする)
これでCSRF対策は問題ないレベルにはなる
副作用を起こすにはAuthorizationヘッダーがなければならないので不可能。Top Level NavigationではCookieは付くがAuthorizationヘッダーが送信されることはない。
またブラウザに脆弱性が存在して任意のヘッダーが付与できたとしても、Authorizationヘッダーは推測困難であるためリスクは十分に低減できる。
更に安全側に倒して、フレームワーク化する
Cookieが必要になるNext.jsのSSR/RSCのコンテキストでは、基本的にステートレスであるためread-onlyなインスタンスのみを利用可能にする。
その他のXHR経由のエンドポイントではCookieは読まず、Authorizationヘッダーを強制する。
こうすることで実装者が間違ってwriteにアクセスしてしまうケースを保護しつつ、writeが可能な場所をディレクトリ構造として分離する。
Cookieにwrite用のトークン別においておいて SameSite=Strict にする戦略も一貫してcookieを見に行けばよいのでシンプルで良いかなと思ったのだが、あえてCookieを圧迫する必要もないのでやめた。それとSameSite対応されていない古いブラウザの問題もある。
通信量の観点では、cookieとAuthorizationの両方が乗ってしまうケースがあって不利なこともある。ただ、jwtのheader部分はread用,write用で同一になるので削減できるかもしれない。jwtのフォーマットは、header.payload.signature というように.で区切られていてそれぞれが独立。そのため、cookieが乗ってしまう場合にはAuthorizationの方のheader部分を削減可能。ただjwtのスペックを壊すのも微妙ではある(実装者とのコンセンサスが"jwt"という名前で取れなくなる)ので、要検討。
あとから気づいたメリットとして、read用のトークンとwrite用のトークンとで有効期限を別々に設定できる点がある。write用のトークンは1hなど短めに設定しておいて漏洩リスクに対して厳し目に設定するなどが可能である。基本的にwrite用のトークンはXHRのみなのでリクエストに失敗したらrefreshトークンで再取得すれば良い。ただread用トークンの有効期限を短めにしてしまうとSSRのメリットが活かせなくなってしまう。ここで別々に有効期限を設定することができることで、ユーザビリティとセキュリティ要件のトレードオフに対して柔軟に対応できるようになる。
参考にした記事🙏
追記
Cookieに保持しているtokenにhttpOnlyは必要だろうか?とふと思った。とりあえずjsからアクセスしないのであれば付けておけばよいという話はあるが、tokenへのアクセス困難さに関してはjsからアクセスできたところで外しても変わらないので、外しても問題ない気がした。というのも、そもそもXSSに晒されているのであれば、LocalStorageに保持しているrefreshトークンにアクセスできてしまうし、その他にもビルトインの関数を上書きしたりなんでもできてしまって為す術もない。堅牢性の観点から言えば、変わらない気がしている。それであればtokenの有効期限を確認できたりするのでjsからのアクセスを許可しておいたほうが便利が良いと思ったのだ。
更に、そもそもrefresh tokenへアクセスし易すぎる問題があるのか?という点もある。tokenをLocalStorageに置きっぱなしにしていることがそもそものリスクであるとも考えられる。すべてのtokenをhttpOnlyのcookieに載せておけばXSSにおいてある程度の効果はあるかもしれない。例えば頻繁にはアクセスしない特別な認証認可のための専用ドメインのhttpOnlyのcookieに乗せておく。ドメインが分かれているので、頻繁にネットワークに乗ることはなくリスクを減らせる。そして何らかの方法でrefreshした後にget/writeのtokenも同様にhttpOnly cookieに載せる。もちろんcookieに乗るので古典的なCSRFは必要になる。これですべてのtokenはjsからアクセスできなくなる。token自体を盗むことはかなり困難になるはずである。
とはいえ、攻撃者の目的を勘違いしてはいけない。XSSされてしまえばtokenを奪取せずとも目的の操作ができてしまう可能性は十分にある。token自体を隠蔽することにここまで固執する必要はそこまで無いとは思う。しかし、攻撃の自由度は下がるという点では一定の効果はあるかもしれない。
議論点からは外れてしまうが、より保護すべきはクリティカルな操作に対する堅牢性であるはずである。必要な箇所では再認証などを通すなど段階的なセキュリティレベルを設けて、利便性と堅牢性のトレードオフを議論することがやはり最も重要である。
さらに、もともとは利便性を損なわず堅牢性を高めようとする議論であったが、システムの堅牢性に直結する品質に関わる部分、たとえばシステムの複雑性など別の論点も見落としてはいけない。