Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Cloud StorageのstoragePathはRLSが効かず他校ファイルパスを読める

公開 読了時間 約9分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

GCSはPostgreSQLのRLSを尊重しないため、クライアント申告のstorage_pathを無検証で受け付けるAPIがあると、他校のアップロードprefixをそのまま登録でき、一覧・ダウンロードで越境読取が開く。

結論

PostgreSQLのRLS(Row Level Security)はDBの行だけを守り、Cloud Storageのオブジェクトパスには一切効力が及ばない。実体アップロード用のエンドポイントはper-school prefixでこれを守っていたが、添付メタ情報だけを登録する別のエンドポイントは、クライアントが申告したstoragePathをその保護の外で直接DBへ保存していた。他校のprefixを申告すればそのまま登録でき、一覧・ダウンロードで越境読取が開く状態になっていた。

  • 実体アップロードのエンドポイントは、保存キーをサーバー側でuploads/{schoolId}/{サーバー生成UUID}.{拡張子}として生成し、クライアントがpathを指定する余地自体がない
  • 添付メタ情報だけを登録する別のエンドポイントは、クライアント申告のstoragePathをフォーマットだけ検証して信用していた
  • RLSは行レベルの保護なので、「object pathが自校prefix内かどうか」はRLSの対象外。別の検証関数を自分で書かない限り、誰も見ていなかった

症状

教員入力の添付ファイルは、2段構成のAPI(クライアントとサーバーがデータをやり取りする窓口)で扱われていた。実体のアップロードは署名付きの別経路で済んでおり、今回問題になったのは、アップロード済みのファイルを入力行に紐づけて記録するだけの、メタ情報登録専用のエンドポイントだった。

// apps/web/app/api/teacher-inputs/[id]/attachments/route.ts
/**
 * F02 (FR-05, メタ行のみ): 教員入力の添付メタエンドポイント (ADR-008 Route Handlers)。
 *
 * - POST /api/teacher-inputs/:id/attachments  … 添付メタ登録
 *     クライアントが別経路で Cloud Storage にアップロード済みの storage_path を登録するだけ。
 */
const createSchema = z.object({
  storagePath: z.string().min(1).max(2_000),
  mimeType: z.string().min(1).max(255),
});

export async function POST(request: Request, context: RouteContext): Promise<NextResponse> {
  // ...
  const parsed = createSchema.safeParse(body);
  // ...
  const row = await withSession(
    (tx: TenantTx, user) => addAttachment(tx, user.uid, id, parsed.data),
    { allowedRoles: TEACHER_INPUT_STAFF_ROLES },
  );
  // ...
}

createSchemaはstoragePathを「1〜2000文字の文字列」としてしか検証していない。文字列の形式さえ整っていれば、それがどの学校のprefixを指しているかは問わず、そのままaddAttachmentでDBに登録される。

対照的に、実体のファイルを保存するupload-storage.ts側には、保存キーの組み立てを一箇所に集約する関数と、その設計意図を説明するコメントがあった。

/**
 * ## per-school オブジェクト prefix(テナント分離・PR #516 Reviewer Medium)
 * GCS は PostgreSQL RLS を尊重しない。バケットは校横断の単一バケットなので、保存キーを
 * **`uploads/{schoolId}/...` の per-school prefix** に必ず収め、将来 IAM condition で prefix 単位の
 * アクセス境界を張れるようにする(cross-tenant な object 読取を構造的に防ぐ土台)。保存キーの
 * 末尾は **サーバ生成 UUID + MIME 由来拡張子**で、クライアントのファイル名は path に使わない
 * (path traversal 防止、NFR03)。
 */
export function buildUploadObjectPath(schoolId: string, objectId: string, ext: string): string {
  // schoolId / objectId に "/" を含む場合は RangeError
  return `uploads/${schoolId}/${objectId}.${ext}`;
}

このコメント自体が「GCSはPostgreSQL RLSを尊重しない」と明記している。実体アップロード側は、この前提を踏まえてper-school prefixをサーバー側で強制する設計になっていた。しかし、メタ情報だけを登録する別のエンドポイントは、このbuildUploadObjectPathを経由せず、クライアントの申告をそのまま信用していた。

原因

実体アップロードとメタ情報登録が別々のエンドポイントに分かれていたこと自体は、設計として妥当だった(署名付きURLでの実アップロードとDB登録を分離するのは一般的な構成)。問題は、「storage_pathがどの学校のprefix内にあるべきか」というテナント境界の検証が、どちらのエンドポイントの責務としても明示的に書かれていなかったことにある。

実体アップロード側は、パスをサーバー側で生成するため、他校prefixを指す余地がそもそも存在しない。つまり「書く側」では問題が起きない構造になっていた。一方、メタ情報登録側は「すでに正当にアップロードされたファイルを登録するだけ」という前提に立っていたため、申告されたstoragePathの検証はフォーマット(文字数・非空)だけで十分だと見なされていた。

RLSはテナント分離の主要な防御層として機能していたが、RLSが保護するのはPostgreSQLの行であり、GCSのオブジェクトパスという文字列そのものの正当性ではない。「他校のprefixを申告したらどうなるか」という問いは、RLSの効力の範囲外にあり、どちらのコードレビューでも見落とされていた。

直す

upload-storage.tsに、buildUploadObjectPathの生成規約と対になる検証関数を新設した。

/**
 * 申告された `storagePath` が指定 school の per-school upload prefix 内かを検証する(越境登録防止)。
 *
 * `buildUploadObjectPath` が生成する `uploads/{schoolId}/...` 規約と対になる検証関数。GCS は
 * PostgreSQL RLS を尊重しないため、クライアント申告の object path をそのまま attachment として
 * 登録できると、他校 prefix の path を登録 → listAttachments / ダウンロードで cross-tenant な
 * object を読まれる経路が開く。登録時に自校 prefix 内に限定して構造的に塞ぐ。
 */
export function isWithinSchoolUploadPrefix(
  storagePath: string,
  schoolId: string | null | undefined,
): boolean {
  if (!schoolId || schoolId.includes("/")) {
    return false;
  }
  if (!storagePath || storagePath.includes("..")) {
    return false;
  }
  return storagePath.startsWith(`uploads/${schoolId}/`);
}

メタ情報登録のPOSTハンドラ側では、認可ゲート(allowedRoles)を通過した後、認証済みユーザーのuser.schoolIdを使ってこの検証を通すようにした。

const row = await withSession(
  (tx: TenantTx, user) => {
    // 越境登録防止: 申告 storagePath は自校 prefix (uploads/{schoolId}/) 内に限る。
    // 認可ゲート(allowedRoles)通過後に user.schoolId で検証する(クライアント由来 path を信頼しない)。
    if (!isWithinSchoolUploadPrefix(parsed.data.storagePath, user.schoolId)) {
      throw new ForbiddenError();
    }
    return addAttachment(tx, user.uid, id, parsed.data);
  },
  { allowedRoles: TEACHER_INPUT_STAFF_ROLES },
);

検証に使うschoolIdは、リクエストボディではなく認証済みセッションのuser.schoolIdから取る。クライアントが申告するのはstoragePathだけで、「どの学校のものと見なすか」の判断はサーバー側のセッション情報に委ねている。

あわせて、申告mimeTypeも実体アップロードと同じallowlist(resolveUploadType)に通すようにし、許可外の形式は415で拒否するようにした。これはテナント境界とは別の防御だが、「クライアント申告の値を無検証でDBに登録する」という同じ構造の穴だったため、同じ修正でまとめて塞いだ。

新設したisWithinSchoolUploadPrefixには、自校prefix内は許可、他校prefixは拒否、uploads/以外のprefixは拒否、..混入は拒否、schoolIdが空または区切り文字を含む場合は拒否、という単体テストを追加している。

it("他校 prefix の path は拒否(cross-tenant 登録を構造的に塞ぐ)", () => {
  const other = "33333333-3333-3333-3333-333333333333";
  expect(isWithinSchoolUploadPrefix(`uploads/${other}/obj.pdf`, SCHOOL)).toBe(false);
});

it(".. 混入は拒否(path traversal)", () => {
  expect(isWithinSchoolUploadPrefix(`uploads/${SCHOOL}/../../etc/x`, SCHOOL)).toBe(false);
});

再発しにくい形

「RLSで守られているはずのテーブル」の周辺に、RLSの対象外のデータ(この場合はオブジェクトストレージのパス文字列)を受け取るエンドポイントがあると、そこがRLSの効力範囲の外にある抜け道になる。RLSを導入したこと自体は、DBの行に対しては正しく機能していた。見落とされていたのは、「RLSが守る対象と、実際にテナント境界をまたいで読まれうる対象が一致しているか」という確認だった。

同種の構成(単一バケット・per-school prefixでのテナント分離)を扱うときは、object pathを生成する側だけでなく、object pathを外部から受け取って記録する側すべてに対して、同じprefix検証を通しているかを横断的に確認する必要がある。片方にだけ防御があれば十分という思い込みが、この経路を見逃させていた。

よくある質問

Q1なぜRLSがあるのにこの穴が開いたのですか?

RLSはPostgreSQLの行レベルのアクセス制御で、Cloud Storageのオブジェクトには一切及びません。実体アップロード用のエンドポイントはuploads/{schoolId}/...というper-school prefixを守る仕組みを持っていましたが、添付メタ情報だけを登録する別のエンドポイントは、クライアントが申告したstorage_pathをその仕組みを経由せず直接DBへ保存していました。RLSはDBの行は守っても、GCS上のオブジェクトパスそのものを守る仕組みではありません。

Q2具体的にどんな経路で越境読取が開いていましたか?

教員入力の添付メタ登録API(POST /api/teacher-inputs/:id/attachments)は、storagePathとmimeTypeをクライアントの申告どおりにzodでフォーマットだけ検証し、内容の整合性は検証せずDBへ登録していました。他校のuploads/配下のobject pathを申告してもそのまま通るため、登録後はlistAttachmentsの一覧やダウンロードで、別のテナントが保存したファイルを読める状態になっていました。

Q3実体アップロード側ではこの問題は起きないのですか?

起きません。実体アップロード側は保存キーをサーバー側でuploads/{schoolId}/{サーバー生成UUID}.{検証済み拡張子}として生成しており、クライアントがpathを指定する余地自体がありません。穴があったのは、実体アップロードとは別に用意された、メタ情報だけを登録するエンドポイントの方でした。

Q4修正ではどういう検証を追加しましたか?

isWithinSchoolUploadPrefixという関数を新設し、申告されたstorage_pathが認証済みユーザーのuser.schoolIdに対応するuploads/配下に収まっているかを検証し、収まっていなければ403で拒否するようにしました。あわせて、申告mimeTypeも実体アップロードと同じresolveUploadTypeのallowlistに通し、許可外なら415で拒否するようにしています。

Q5なぜこのAPIだけ検証が抜けていたのですか?

このAPIはもともと『クライアントが別経路で既にCloud Storageへアップロード済みのstorage_pathを、入力行に紐づけて登録するだけ』という設計で作られ、ファイル自体の正当性は実体アップロード側で検証済みという前提に立っていました。しかしstorage_pathがどの校のprefixを指しているかという、テナント境界の検証はどちらのエンドポイントの責務でもなく、両者の間に抜けていました。

確認した環境

  • Next.js ^16.4.2 / drizzle-orm ^0.45.2 / @google-cloud/storage ^7.19.0 / zod ^4.6.7 / vitest ^4.1.12
  • 教員入力の添付メタ登録API。2026-06-21のPRで修正

この記事の根拠

  • TypeScriptファイル 1〜51行目コミット 0476129
  • TypeScriptファイル 1〜87行目コミット 0476129
  • TypeScriptファイル 53〜76行目コミット a12b7ef
  • TypeScriptファイル 1〜108行目コミット a12b7ef
  • TypeScriptファイル 39〜65行目コミット a12b7ef

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。