Codex と Claude のレビューで浮かんだ「CSRF 保護」まわりの観察を、勉強のためにまとめたものです。
綴Photo の Web 側(Django REST Framework, 以下 DRF)のレビューで、書き込み系 API のうち1つだけ CSRF 保護が付いていない箇所が見つかった。同種の API はすべて共通の仕組みで保護していたのに、後から足したエンドポイントが1つだけその仕組みを継承し忘れていた、というパターンだった。
ここでの API は「外部からデータを読み書きするための窓口」、書き込み系 API(エンドポイント) は「その窓口のうち、データを保存・更新する側」くらいの意味で読んでほしい。今回穴が空いていたのは、その「保存・更新する窓口」の1つだ。
この記事では、実コードの細部ではなく「なぜこういう穴が空くのか」を構造として残す。
この記事で扱う観察ポイント
- そもそも CSRF とは何で、なぜ「Cookie 認証」のときだけ問題になるのか
- DRF では認証クラスによって CSRF の自動強制が効いたり効かなかったりする
- 共通の防御を全エンドポイントに横展開したのに、1箇所だけ漏れる構造
Cookie 認証の書き込み API に CSRF 保護が無かった
ここで重要なのは、CSRF が問題になるのは Cookie を認証に使っているときだという点です。
リクエストごとに Authorization: Bearer <トークン> のようなヘッダーを明示的に付ける認証(モバイルアプリでよくある形)なら、CSRF は基本的に問題になりません。なぜなら、悪意あるページは別サイトのトークンをヘッダーに乗せられないからです(ブラウザが自動で付けるのは Cookie だけで、ヘッダーは付けてくれません)。
綴Photo の Web は「Web ブラウザは Cookie 認証、モバイルは Bearer 認証」という二刀流になっています。
コードで起きていたこと
DRF には「認証クラス」という概念があり、リクエストを誰として扱うかを決めます。綴Photo では「Bearer ヘッダーがあればそれを使い、無ければ Cookie のトークンを使う」というカスタム認証クラスを全 API のデフォルトにしていた。
つまり、どの書き込み API も Cookie 認証で叩けます。だから書き込み系には一律で「Cookie のときだけ CSRF チェック、Bearer のときはスキップ」というデコレータを dispatch(リクエストの入口)に当てる共通クラスを用意し、各 API にそれを継承させていた。
ここで「継承」とは、共通の親クラスから設定や振る舞いを”受け継ぐ”仕組みのことです。共通の見張り(CSRF チェック)を親クラスに置いておけば、そこから作られた子は自動でその見張りを引き継ぎます。
ところが、後から追加した同期用の書き込みエンドポイントが1つだけ、この共通クラスを継承せず素の API クラスを直接継承していた。
# 共通の防御を継承しているエンドポイント(正しい)
class SomeWriteViewSet(CsrfUnlessBearerMixin, viewsets.ModelViewSet):
...
# 後から足したエンドポイント(CsrfUnlessBearerMixin を継承し忘れ)
class SyncSomethingView(APIView): # ← 素の APIView 直継承
def post(self, request):
...なぜそうなりやすいか
ここがいちばんの学びだった。DRF は認証クラスによって CSRF の自動強制が効いたり効かなかったりします。
つまり、フレームワークの安全網の外側に出る設計(JWT を Cookie で運ぶ)を選んだ時点で、「CSRF 対策は自分で全エンドポイントに配る」責任が発生していた。配り方を共通クラスの継承にしていたため、継承を忘れた1箇所が静かに穴になります。「うっかり」ではなく、こういう設計だと誰でもハマる構造です。
ここまでの「認証のやり方 × CSRF の要否 × フレームワークが自動で守るか」を一覧にすると、綴Photo の置かれた位置がはっきりします。
| 認証のやり方 | Cookie に依存? | CSRF 対策の要否 | DRF が自動で守る? |
|---|---|---|---|
| SessionAuthentication | 依存する | 要 | 自動で守る |
| JWT(Bearer ヘッダー) | 依存しない | 不要 | (対象外) |
| JWT を Cookie に入れる(綴Photo) | 依存する | 要 | 守らない |
最後の行だけが「対策は要るのに、フレームワークは自動で守ってくれない」という組み合わせになっています。穴が空いたのが、まさにこの位置だ。
改善の方向
直し方自体は単純で、漏れていたエンドポイントにも共通クラスを継承させるだけ。
class SyncSomethingView(CsrfUnlessBearerMixin, APIView):
def post(self, request):
...構造的な再発防止としては、テストで「Cookie 認証 + CSRF トークン無しの POST は拒否される(403)」を1本書いておくと、新しい書き込み API を足したときに継承忘れを機械的に検出できます。横展開した防御は「テストで一律にかける」のが、継承忘れより信頼できます。
学びポイント
- CSRF は「Cookie で認証しているとき」だけ問題になります。Bearer ヘッダー認証なら基本不要です
- 「Django だから CSRF は自動で守られている」は 認証クラスが SessionAuthentication のときだけ。トークン認証では自動強制は効きません
- フレームワークの安全網の外に出る設計(JWT を Cookie で運ぶ等)を選んだら、その分の防御は自分で全エンドポイントに配る責任が生まれます
- 「継承で防御を横展開」は、継承忘れが穴になります。一律にかかるテストで担保するほうが堅いです
このテーマの括り
今回を一言でまとめると、JWT を Cookie に入れた特殊型だから、Web 経路にだけ CSRF の「合言葉」が要ります。なのに、その合言葉チェックを書き込み API の1つで付け忘れていた、という話だった。
CSRF 保護漏れは「悪い書き方をした」というより、フレームワークがどこまで自動で守ってくれるかの境界を見誤ると起きます。SessionAuthentication の内側にいれば自動で守られ、トークン認証に出た瞬間に自前防御が要ります。境界の内と外で責任が切り替わることを意識すると、この種の穴は見つけやすくなります。
おわりに
この種の穴は「フレームワークが面倒を見てくれる範囲」と「自分で見る範囲」の境目で起きやすいです。便利なフレームワークほど「勝手に守ってくれている」と思い込みやすく、その思い込みが外れる箇所(今回なら Cookie + トークンのハイブリッド)が盲点になります。レビューで第三者(AI)に見てもらうと、こういう「前提のズレ」を拾えます。
この記事は2026年5月時点の自分の理解で書いています。Django/DRF の CSRF まわりの挙動はバージョンや設定で変わることがあるので、最新の公式ドキュメントも合わせて参照してください。