管理画面の認証が `/Admin` で抜けた話
Cloudflare Access で保護したつもりの管理画面に、/Admin という別表記から認証を迂回できる穴がありました。
実装直後のレビューで見つかり、ローカルの wrangler dev で管理画面の loader/action が実行されることを確認しました。
原因は、Worker の認証ゲートとアプリのルーターで、大文字小文字の扱いが違っていたことです。
パスの正規化と各処理での再認可を入れ、追加レビューで /admin.data の抜けも修正した経緯を書きます。
Access と Worker で守っていた管理画面
私の個人ブログは、Cloudflare Workers、D1、R2、react-router v7 の SSR で作っています。管理画面は /admin 配下に置き、Cloudflare Access のメールによるワンタイムコードで保護しました。
Worker 側でも、リクエストの Cf-Access-Jwt-Assertion を JWKS で検証する構成にしていました。確認するのは、RS256 による署名、対象を示す aud、許可したメールアドレスです。入口の Access に加えて、アプリを動かす Worker にも検証を置いていました。
それでも穴ができたのは、JWT の検証処理を呼び出す条件に問題があったからです。検証そのものを用意していても、管理画面に届くリクエストがその処理を通らなければ守れません。
なお、今回の再現はローカルの wrangler dev で行ったものです。公開環境で Cloudflare Access 自体を突破できたと確認した話ではありません。確認できたのは、Worker 側のゲートを通らずに、アプリの管理処理へ到達する経路があったことです。
/admin と /Admin の解釈が食い違った
問題のゲートは、次の条件で管理画面へのリクエストを判定していました。
path === "/admin" || path.startsWith("/admin/")
この文字列比較は、大文字小文字を区別します。/admin や /admin/ で始まるパスは対象になりますが、/Admin は対象になりません。
一方、今回使っていた react-router のルートマッチは、既定では大文字小文字を区別しませんでした。同じリクエストに対して、Worker とルーターが異なる判断をしていたことになります。
| リクエスト | Worker のゲート | ルーターの判断 |
|---|---|---|
/admin |
認証の対象 | 管理ルートに一致 |
/Admin |
認証の対象外 | 管理ルートに一致 |
このずれにより、/Admin は Worker の認証ゲートを素通りし、その先で管理ルートとして処理されました。ローカルで再現すると、管理画面の loader/action が実行されました。
問題は、URL の見た目が少し違うことではありません。「これは管理画面へのアクセスか」という判断を複数の場所で行い、その一致条件がそろっていなかったことです。
Access で /admin 配下を守り、Worker にも検証を置く。そこまで実装していても、ルーターが受け付ける別表記を考慮できていませんでした。
修正はパス判定と各処理の両方に入れた
修正では、まず Worker で判定に使うパスを正規化しました。処理の順序は、デコード、小文字化、連続するスラッシュの畳み込みです。デコードに失敗したリクエストは、HTTP 400 を返すようにしました。
これで、表記の違いによって管理パスの判定から外れる経路を減らします。ただし、正規化を追加しただけで、すべての経路を扱えると考えるのは危険でした。その点は、後の /admin.data の確認でも分かります。
次に、管理ルートすべてに caseSensitive: true を指定しました。今回問題になった大文字小文字の扱いを、ルート定義でも明示するためです。
さらに、すべての管理用 loader/action の先頭で、再度認可するようにしました。Worker のゲートを通ったはずだという前提だけで、管理処理を実行しない構造にしたかったからです。
各修正の役割は少しずつ違います。
- パスの正規化で、Worker が認証対象を見落としにくくする
caseSensitive: trueで、管理ルートの一致条件を明示する- loader/action の先頭で再認可し、処理の実行直前でも止める
認証処理を入口に置くことと、実際の管理処理が認可を必ず確認することは、分けて考える必要がありました。どちらかの条件に漏れがあっても、もう一方で止められるようにしています。
約20種を確認して見つかった /admin.data
修正後のレビューでは、約20種の迂回パターンを実機で確認しました。確認対象には、二重エンコード、バックスラッシュ、ドットセグメント、; パラメータ、末尾ドット、single-fetch の /admin.data などを含めました。
ここで見つかったのが、/admin.data の扱いです。このパスは Worker のゲートを抜けていました。
/admin.data は /admin そのものではなく、/admin/ で始まるパスでもありません。大文字小文字をそろえるだけでは解決しない、別の判定漏れでした。管理画面の URL を確認するだけでなく、データ取得に使われる経路も認証対象として確認する必要がありました。
この時点では、先に追加した loader 内の再認可がリクエストを止めていました。Worker 側の判定に漏れが残っていても、管理処理の直前で止まったわけです。多層防御が実際に機能した箇所でした。
ただし、loader で止まることを理由に、ゲートの漏れは残しませんでした。Worker 側にも /admin.data を対象として追加しました。
この確認から、パスの正規化と、認証対象となる経路の網羅は別の作業だと分かりました。表記をそろえても、判定条件に含めていない経路は残ります。
レビューの観点を分ける意味があった
実装直後には、別の観点によるレビューも並列で走らせていました。TypeScript 品質を確認したレビューは、同じコードを「マージ可」と判定していました。一方で、認証バイパスを見つけたレビューもありました。
この結果だけで、TypeScript 品質のレビューが無意味だったとは考えていません。ただ、「マージ可」という結論を読むときには、何を確認したレビューなのかも一緒に見る必要があります。
型やコードの品質を確認することと、リクエストが認証を迂回して管理処理へ到達しないかを確認することは、異なる問いです。今回は観点を分けて並列に確認したことで、片方では見つからなかった問題を拾えました。
あわせて、周辺の実装も修正しました。403 や 400 の非 HTML 応答にもセキュリティヘッダを付け、キャッチオールの404ページが HTTP 200 を返していた点を直しました。JWT の alg は明示し、CSP は nonce 方式にしました。
認証の分岐だけでなく、拒否したときの応答や、存在しないページのステータスまで確認したことで、画面が表示されるかどうかだけでは気付きにくい問題も修正できました。
なお、ここに書いた修正はすべて公開環境に反映済みです。記事中の再現条件は、修正前のコードに対するものです。
学び
- パスで認可するなら、ゲートとルーターが同じ URL をどう解釈するかを確認します。大文字小文字やエンコードを変えたリクエストも、検証対象に含めます。
- 正規化の手順と、デコード失敗時の応答を明確にします。正規化だけで認証対象の経路を網羅できるとは考えません。
- 管理用 loader/action は、処理の先頭でも認可を確認します。入口の判定漏れが、そのまま管理処理の実行につながらない構造にします。
- 画面の URL に加えて、データ取得に使う経路も確認します。今回の
/admin.dataのように、単純な配下判定から外れるものがありました。 - レビューの結論は観点とセットで受け取ります。TypeScript 品質の確認に加え、認証を迂回できる経路がないかを独立して確認します。