前回は、権限の検証が「他人のデータが無かったから」通ってしまった話でした。
今回は、同じバグが4日間隠れていた理由のふたつめです。
こちらは、自分が良かれと思ってやったことが原因でした。
絞り込みを2箇所でやっていました
データベースを設計した工程で、こういう方針を決めていました。
アプリを信用せず、DB で守る。
アプリのコードは書き忘れます。条件を1つ落としても、たいてい動いてしまいます。
DB の制約やポリシーなら、どこから来たクエリにも必ず効きます。
なので、絞り込みは2箇所に置くことになりました。
| 層 | やっていること |
|---|---|
| DB(RLS) | 行レベルで、他人のデータを返さない |
| アプリ | クエリに .eq('user_id', ...) を書いて、自分の分だけ取る |
設計としては、これで正しいつもりでした。
むしろ丁寧なほうだと思っていました。
同じ穴が、片方の画面では出て、片方では出ませんでした
ところが DB 側は、最初から効いていませんでした。
シリーズ2本目に書いたとおり、ポリシーが OR で結合されていて、
ログイン中は他人の公開カードまで読める状態がずっと続いていたのです。
面白かったのは、画面によって出方が違ったことでした。
管理画面には出ました。
ここは方針どおり .eq を書かず、絞り込みを RLS に任せていたからです。
DB が守っていなかったので、そのまま他人のカードが並びました。
公開ページには出ませんでした。
こちらは URL のユーザー名から .eq('user_id', profile.id) で絞っています。
RLS が余計な行を返していても、アプリ側が捨てていました。
同じ穴です。同じテーブル、同じポリシー、同じ日。
それでも片方の画面は正常に見えていました。
守っている層が、壊れている層を隠します
もし管理画面のほうにも .eq を書いていたら、どうなっていたか。
両方の画面が正しく表示されて、穴は見つかりませんでした。
そのまま公開まで進んでいた可能性が高いと思います。
「DB でも守っているから安全」という認識だけを持ったまま、
実際には1枚で走っている状態が続いていたはずです。
ここが二重防御の裏側でした。
層を増やすと、片方が壊れても表に出ません。
出ないので直せない。直らないまま、残った1枚に全部を預けることになります。
そして厄介なのは、手応えとしては安心が増えることです。
守りを足したので、感覚としては前より堅くなっています。
片方を外して確かめる手段が要りました
今回それを担ったのは、動作確認用の SQL でした。
begin;
set local role authenticated;
set local "request.jwt.claims" = '{"sub":"a12690b6-…"}';
select count(*) filter (where user_id <> 'a12690b6-…') as 他人の行数
from public.items; -- 期待: 0
rollback;
ロールを切り替えて、DB に直接投げています。
アプリを経由しないので、アプリの絞り込みが無い世界を再現できました。
アプリ側のコードを一時的に消して確かめる、という方法もあります。
ただ、それは面倒ですし、消したまま忘れる事故のほうが怖い。
DB 単体で叩ける検証を置いておくと、層を1枚ずつ剥がして見られます。
あってよかったと、はっきり思いました。
おわりに
「アプリを信用せず DB で守る」の裏側に、こういう面がありました。
アプリでも守ると、DB が守っていないことが見えなくなる。
多層防御をやめる、という話ではありません。
今回それが無ければ、公開ページのほうから他人のデータが漏れていました。
そうではなくて、層を増やしたら、層ごとに確かめる手段も増やすという話だと思っています。
2枚重ねたなら、2枚重ねた状態で見るだけでは足りません。
このシリーズでは、RLS でつまずいた4つを順番に書いてきました。
権限は GRANT と RLS の二段構えで、ポリシーだけでは1行も読めませんでした。
ポリシーは足すほど通り道が増えて、他人のカードが見えました。
検証が通ったのは、そのとき他人のデータが存在しなかったからでした。
そして4日間気づけなかったのは、もう1枚が正しく動いていたからでした。
4つとも、間違っていたのは書いたコードではなく読み方のほうです。
足せば締まる、通ったから正しい、守りは多いほど良い。
どれも自然に感じる読み方で、どれも今回は逆でした。
自分の直感がどこで裏返るかは、たぶん踏まないと分かりません。
そのぶん、踏んだ場所はよく覚えています。
