参照(GET)では守っていた本人確認を、更新(POST)がまるごと落としていた — 口座APIで作りかけたIDOR
はじめに
個人で、口座を扱う銀行系のシステムを、実際に動くところまで作っている。構成・画面・背骨となるコードは 自作の銀行系システムは実際に何が動いているか に一巡した。口座の残高照会や入出金は、顧客向けのWeb画面(React)から行う。下は自分の口座が並ぶ口座一覧だ。

この記事は、その画面の裏側にある口座APIで、出荷前に一つ塞いだ穴の話だ。
穴の名前は IDOR(Insecure Direct Object Reference)。ひとことで言えば、**「ログインは正しく通っているのに、URLの口座番号を他人のものに差し替えると、その口座を操作できてしまう」**欠陥だ。認証(あなたは誰か)は通っていた。抜けていたのは認可(あなたはこの口座を触ってよいか)のほうだった。
何が起きうるか
このAPIでは、開発の順序として参照系(GET)を先に固めていた。残高照会や取引履歴は、認証された本人の口座だけを返すように所有権チェックが入っていた。ここは正しかった。
問題は更新系(POST)だ。出金や振込の設計(疑似コード)には、その所有権チェックが書かれていなかった。疑似コード通りに素直に実装すると、こうなる。
認証済みリクエスト(本人であることは確認済み)
│
├─▶ GET /accounts/{id}/balance(参照系)
│ └─ 所有権チェックあり ─▶ 自分の口座だけ返す ○
│
└─▶ POST /accounts/{id}/withdraw(更新系)
└─ 所有権チェック無し
│
└─▶ {id} を他人の口座に差し替える ─▶ 他人の口座から出金 ×
▲
(認証は通っているのに、認可だけ抜けている)
ログイン自体は正規のものだ。だから一見、正しく守られているように見える。だが更新系のエンドポイントは、URLの {id} を自分のものかどうか確かめていない。認証済みでありさえすれば、他人の口座番号を指定して出金や振込を実行できてしまう。金融のAPIで、これはそのまま事故になる。
実装の形で見ると、抜けはこうだ。前提記事で見た参照系(残高照会)には所有権チェックがあったのに、更新系のメソッドにはそれが無い。
// 更新系(出金)— 所有権チェックが無い版
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
@RequestBody WithdrawRequest req,
@AuthenticationPrincipal Jwt principal) {
// principal(本人)を受け取っているのに、
// id の口座が本人のものかを一度も確かめていない
accounts.withdraw(id, req.amount()); // ← 他人の口座 id でも通ってしまう
}
本人(principal)は手元にある。それなのに、id の口座が本人のものかを突き合わせていない。参照系では当然のようにやっていたその照合が、ここには無い。
なぜ起きたのか
原因は二つ重なっていた。
一つは、認証と認可を同じものとして扱ってしまう油断だ。 認証は「あなたが誰か」を確かめる。認可は「あなたがこの操作・この対象を許されているか」を確かめる。ログインが通れば安全、ではない。ログインした人が、他人の口座まで触れてよいわけではない。この二つは別の関門だ。
もう一つは、参照系で確立したパターンが、更新系に自動では引き継がれないことだ。 参照系を先に作り込んで所有権チェックを確立しても、更新系の設計を疑似コードだけで済ませると、そのチェックがコピーされないまま残る。そして疑似コードを「仕様だから」と機械的に実装すると、抜けたまま形になる。開発の順序(参照系が先、更新系は疑似コードのみ)が、静かにギャップを生んでいた。
つまりこれは、個別のうっかりミスというより、開発の進め方が生む構造的な穴だった。
どう塞いだか
更新系にも、参照系と同じ所有権チェックを移植した。認証で取り出した本人と、操作対象の口座の所有者が一致しなければ、実行を拒否する。
POST /accounts/{id}/withdraw(更新系)
│
├─▶ 認証:トークンから本人(sub)を取り出す
│
└─▶ 認可:{id} の口座の所有者 == 本人か?
├─ 一致 ──▶ 出金を実行 ○
└─ 不一致 ─▶ 拒否(403)× 他人の口座は触らせない
コードにすると、参照系にあった数行を更新系へ移しただけだ。
// 更新系(出金)— 所有権チェックを入れた版
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
@RequestBody WithdrawRequest req,
@AuthenticationPrincipal Jwt principal) {
String me = principal.getSubject();
Account account = accounts.findById(id);
if (!account.getCustomerId().equals(me)) { // 参照系と同じ所有権チェックを移植
throw new AccessDeniedException("not your account");
}
accounts.withdraw(id, req.amount());
}
難しい対策ではない。難しいのは、「参照系で守れているから更新系も守れている」という思い込みに気づくことだった。
そして、直したのはこの一箇所だけではない。URLのパスでリソースのIDを受け取る更新系(POST / PUT / DELETE)は、すべて同じ疑いの対象にした。一つ見つかったということは、同じ順序で作った他のエンドポイントにも同じ穴が空いている可能性が高いからだ。
もう一点、忘れずにやったのは設計書に認可を書き戻すことだ。実装だけ直して設計書を放置すると、次に同じ疑似コードを読んだ人(未来の自分を含む)が、また同じ抜けを再生産する。穴を塞ぐのと、穴の設計図を直すのは、別の作業だ。
なお、窓口オペレーター向けのような「役割(role)で許可する」APIとは前提が違う。あれは職務権限で他人の口座も扱う。今回のような顧客セルフサービスのAPIは、操作できるのは自分の口座に限られる。同じ「口座を操作するAPI」でも、誰のための機能かで認可の形が変わる。ここを混同しないことも、塞ぎの一部だった。
認証を、認可と取り違えない
認証は認可ではない。 「誰か」が分かっても、「それをしてよい」とは限らない。ログインが通っている=安全、と思った瞬間に、認可の関門を飛ばしている。
参照系で確立した認可は、更新系に手で移す。 GETで守れているからと安心しない。POST / PUT / DELETE の設計には、同じ所有権チェックが書かれているかを必ず確かめる。疑似コードは、仕様である前に「まだ認可が抜けているかもしれない下書き」として読む。
URLでリソースIDを受け取る更新系は、全部疑う。 一つ見つけたら、同じ作り方をした兄弟エンドポイントを洗う。穴は単独では空かない。
実装と一緒に、設計書の認可も直す。 実装だけ直すと、設計図が同じ穴をまた生む。塞いだ痕跡を設計に残して、再発の芽を断つ。
疑似コードは、そのまま実装するための正解ではない。参照系で守っている認可が、更新系にも書かれているか——その一行を、手を動かす前に必ず確かめる。
関連する記事
- このシステムの構成・画面・背骨のコードの一巡は 自作の銀行系システムは実際に何が動いているか にまとめた(この記事の前提)。
- こちらが「その人が自分の資源しか触れないか(認可)」なら、その手前の「呼び出し元が誰か(認証)」——人ではないサービス間呼び出しを手がかりを渡さず守る話は 「人ではない相手」を認証する — サービス間の決済呼び出しを、手がかりを渡さずに守る に書いた。
- 要件定義からPoCまでの設計プロセスは 設計から入る銀行系API構築 にまとめた。
- この口座APIを使って、自作プラットフォームの決済から実際に振込を繋いだ話は 自作アプリと自作の銀行を、実際に金が動くまで繋いだ — 決済に口座振込を足し、E2Eで確かめる にある。
- 認証まわりでは、開発中だけ認証を緩めつつ本番の構造を壊さない設計を Spring Profile で dev だけ認証を緩める設計 に書いた。