現代的なCSRF対策
先に結論
副作用を伴わないリクエスト時のread用トークン、DB書き込みなどを行えるwrite用のトークンに分ける
read用のトークンをcookieに詰めてSSR/RSCに利用する
SameSite=Lax にしておくとCross-SiteなPOSTにはcookieが乗らなくなる (これだけでもCSRF対策としてほぼ問題ない)
write用のトークンはXHRのみで利用してAuthorizationヘッダーに入れる
サーバー側でwrite用トークンが検証できた時のみDBなどへの書き込みなど副作用を伴う操作を許容する
AuthorizationヘッダーはXHRで明示的に付与しないと乗らないのでCSRF対策として有効
間違ってる点がありましたら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ではAuthorizationヘッダーが送信されることはない。(CookieにおいてもPOSTリクエストでは SameSite=None にしない限り付与されない。指定なしの場合はデフォルト値がLaxでも付与から2分間はリクエストに乗る可能性アリ)
またブラウザに脆弱性が存在して任意のヘッダーが付与できたとしても、Authorizationヘッダーは推測困難であるためリスクは十分に低減できる。
更に安全側に倒して、フレームワーク化する
Cookieが必要になるNext.jsのSSR/RSCのコンテキストでは、基本的にステートレスであるためread-onlyなインスタンスのみを利用可能にする。
その他のXHR経由のエンドポイントではCookieは読まず、Authorizationヘッダーを強制する。
こうすることで実装者が間違ってwriteにアクセスしてしまうケースを保護しつつ、writeが可能な場所をディレクトリ構造として分離する。
Cookieにwrite用のトークン別においておいて SameSite=Strict にすると、同一ドメイン間での Top Level Navigation でしか送信されなくなるのでCSRF対策になる。この戦略では一貫してcookieを見に行けばよいのでシンプルで良い。しかしGETでも送信されるのでCookieが圧迫される点はデメリット。それとSameSite対応されていない古いブラウザの問題もある。近年ではfetchでリクエストするのが普通だと思うので、Authrorizationを必要なときだけつける方が小回りが利く。
* 依然として Same-Site からの攻撃の場合は防げない点は注意したい。もはや Cross-Site ではないので厳密には CSRF ではないが、Same-Site から開始された攻撃は防げない。この 事例 では Lax のみの対策であるために Same-Site から開始されたフォームからの POST リクエストによって攻撃を受けた。Content-Type: application/json のみ受け入れることで XHR でのリクエスト以外を弾く想定だったが、そのバリデーションが不十分であったために攻撃が通過してしまった。 通信量の観点では、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は必要だろうか?とふと思った。現状はrefresh token, write用access tokenはjsからアクセス可能な場所にある。そしてget用access tokenがcookieに乗っている。とりあえずjsからアクセスしないのであれば付けておけばよいという判断になりがちだが、get用access tokenよりも上位のrefresh tokenがjsの管理化にあるのであれば、cookie上のトークンへjsからアクセスできたところでセキュリティレベルは変わらないのでは?と考えた。というのも、そもそもXSSに晒されているのであれば、LocalStorageに保持しているrefreshトークンにアクセスできてしまうし、その他にもビルトインの関数を上書きしたりなんでもできてしまって為す術もない(オンメモリにトークンを保管したとしてもfetchを書き換えらればすぐ盗める)。jsからアクセス可能な範囲内においてXSS耐性について言及するのはナンセンスだ。堅牢性の観点から言えば、変わらない気がしている。それであればcookie上についても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自体を隠蔽することにここまで固執する必要はそこまで無いとは思う。しかし、攻撃の自由度は下がるという点では一定の効果はあるかもしれない。
議論点からは外れてしまうが、より保護すべきはクリティカルな操作に対する堅牢性であるはずである。必要な箇所では再認証などを通すなど段階的なセキュリティレベルを設けて、利便性と堅牢性のトレードオフを議論することがやはり最も重要である。
さらに、もともとは利便性を損なわず堅牢性を高めようとする議論であったが、システムの堅牢性に直結する品質に関わる部分、たとえばシステムの複雑性など別の論点も見落としてはいけない。