家計簿アプリを作る #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側で確認する
確認の仕方 「塞がったこと」だけでなく「通るべきものが通ること」も確認する
← トップページに戻る