家計簿アプリを作る #93:レシート画像APIの所有者チェック
家計簿アプリ作成シリーズの第93回です。前回レシート画像の削除対応をしている途中で、画像を返すAPIに所有者の確認が無いことに気付きました。今回はそこを塞ぎます。
何が問題だったか
修正前のエンドポイントはこうなっていました。
export const GET: RequestHandler = async ({ params, platform }) => {
const bucket = platform!.env.RECEIPTS_BUCKET;
const object = await bucket.get(params.key);
if (!object) {
return new Response('Not Found', { status: 404 });
}
return new Response(object.body, { /* ... */ });
};
URLで渡されたキーを、そのままR2に問い合わせて返しています。R2はファイルの持ち主を知らないので、「そのキーが本当にアクセスしている人のものか」を誰も確認していない状態でした。キーはUUIDなので推測は困難ですが、何らかの経路でキーが漏れれば、他人のレシート画像がそのまま見られてしまいます。
(auth)グループの中にあっても保護されない
このエンドポイントはsrc/routes/(auth)/api/receipts/[...key]/+server.tsにあります。(auth)グループには認証チェックを行う+layout.server.tsがあるので、一見すると保護されているように見えます。
// (auth)/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
};
しかし、レイアウトのloadが動くのはページを描画するときです。+server.tsのエンドポイントへのリクエストでは動きません。ディレクトリの位置だけを見て「(auth)の中だから保護されている」と考えるのは誤りでした。
実際に、ログアウトした状態でcurlを投げると画像が返ってきます。
curl -I http://localhost:8787/api/receipts/receipts/【UUID】
なお、locals.user自体はhooks.server.tsが全リクエストで設定しているので、エンドポイント側で明示的に見に行けば判定はできます。「値は用意されているが、使うかどうかは各エンドポイント次第」という状態でした。
!は実行時には何もしない
所有者チェックを追加する過程で、一度こう書きました。
eq(transactions.userId, locals.user!.id)
これは型エラーは消えますが、実行時には何のチェックもしません。hooks.server.tsでは未ログイン時にlocals.userへnullが入るので、未ログインのリクエストが来るとnull.idを読もうとして例外になり、404ではなく500エラーが返ります。
TypeScriptの!は「nullではないとコンパイラに伝える」だけのもので、値を保証してくれるわけではありません。このエンドポイントのように「レイアウトの保護が効かない入口」では、実行時のチェックを自分で書く必要があります。
if (!locals.user) {
return new Response('Not Found', { status: 404 });
}
こう書くと、以降のlocals.user.idは!無しで通ります。実行時のチェックと型の絞り込みが一致した状態です。
403ではなく404を返す
他人のレシートを要求されたとき、403 Forbiddenと404 Not Foundのどちらを返すかは迷いどころです。今回は404にしました。
403は「それは存在するが、あなたには見せない」という意味になり、キーが実在することを教えてしまいます。キーが当たっているかどうかが分かること自体が手がかりになるので、「存在しない」と「権限が無い」を区別せず、どちらも同じ404で返す方が安全です。
結果として、次の3つのケースがすべて404で揃いました。
- 未ログイン
- 他人のレシートのキー
- そもそもR2にファイルが無い
認証 → 認可 → データ取得の順番
最初の実装では、R2から画像を取得した後で所有者を確認していました。返す内容は正しいのですが、他人のレシートや未ログインのリクエストに対しても毎回R2から画像本体を取りに行くことになります。
権限の確認は、データを取りに行く前に済ませるのが基本です。順番を入れ替えました。
export const GET: RequestHandler = async ({ params, platform, locals }) => {
// 1. 認証:ログインしているか
if (!locals.user) {
return new Response('Not Found', { status: 404 });
}
// 2. 認可:そのキーが自分の取引のものか
const db = createDb(platform!.env.DB);
const receipt = await db.select({ userId: transactions.userId }).from(transactions)
.where(
and(
eq(transactions.receiptKey, params.key),
eq(transactions.userId, locals.user.id)
)
)
.get();
if (!receipt) {
return new Response('Not Found', { status: 404 });
}
// 3. データ取得:ここまで通ってから画像を取りに行く
const object = await platform!.env.RECEIPTS_BUCKET.get(params.key);
if (!object) {
return new Response('Not Found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
},
});
};
前回のレシート削除のときも、userIdを条件に含めることで他人のファイルを消せないようにしました。R2は持ち主を知らないので、所有者の判断は必ずD1側で行う、という同じ原則が読み書きの両方に当てはまります。
動作確認
キーの値はreceipts/【UUID】という形なので、URLはreceiptsが2回続きます。最初はルーティングのパス、2つ目はreceipt_keyの中身です。
http://localhost:8787/api/receipts/receipts/【UUID】
確認用のアカウントをもう1つ作り、両方のキーを調べてから試しました。
npx wrangler d1 execute kakeibo-app-db --local --command "SELECT u.email, t.receipt_key FROM transactions t JOIN user u ON u.id = t.user_id WHERE t.receipt_key != ''"
- 自分のキー:画像が表示される
- 他人のキー:
Not Found - 未ログイン:
Not Found(修正前は500エラーになっていた)
「他人のキーが404になること」だけを確認すると、実は全部404になっているだけかもしれません。自分のキーがきちんと表示されることも合わせて確認するのが大事でした。
まとめ
| ポイント | 内容 |
|---|---|
| ルートグループの誤解 | (auth)配下でも、+layout.server.tsの認証は+server.tsには効かない |
!の意味 |
型チェックを黙らせるだけで、実行時の保証は無い |
403か404か |
403は対象の実在を教えてしまう。存在しない場合と揃えて404にする |
| 処理の順番 | 認証 → 認可 → データ取得。権限の確認前にデータを取りに行かない |
| 所有者の判断 | R2は持ち主を知らないので、読み書きのどちらもD1側で確認する |
| 確認の仕方 | 「塞がったこと」だけでなく「通るべきものが通ること」も確認する |