storagePath Skips RLS: Cross-Tenant Paths Readable
This article may contain affiliate links. Its content is not affected by advertising.
In short
GCS doesn't honor PostgreSQL RLS, so an API trusting a client-supplied storage_path unchecked can register another school's upload prefix, opening cross-tenant reads via listing and download.
Conclusion
PostgreSQL RLS (Row Level Security) only protects DB rows — it has zero effect on Cloud Storage object paths. The actual-upload endpoint enforced a per-school prefix to guard against this, but a separate endpoint that only registers attachment metadata saved the client-supplied storagePath straight to the DB, outside that protection. Submitting another school’s prefix got registered as-is, opening cross-tenant reads through listing and download.
- The actual-upload endpoint generates the storage key on the server as
uploads/{schoolId}/{server-generated UUID}.{extension}, leaving the client no room to specify a path at all - The separate endpoint that only registers attachment metadata trusted the client-supplied
storagePathafter validating its format alone - RLS is row-level protection, so “is the object path inside our own school’s prefix” falls entirely outside its scope. Unless someone writes a separate validation function for it, nothing is watching
Symptom
Teacher-input attachments were handled by a two-stage API (the interface where client and server exchange data). The actual file upload went through a separate signed-URL path, and the issue here was in the metadata-only registration endpoint, whose sole job is to record an already-uploaded file against an input row.
// apps/web/app/api/teacher-inputs/[id]/attachments/route.ts
/**
* F02 (FR-05, metadata row only): Teacher-input attachment metadata endpoint (ADR-008 Route Handlers).
*
* - POST /api/teacher-inputs/:id/attachments … registers attachment metadata
* Just registers a storage_path the client already uploaded to Cloud Storage via a separate 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 validates storagePath only as “a string of 1 to 2000 characters.” As long as the string format checks out, it gets registered to the DB via addAttachment with no regard for which school’s prefix it actually points to.
By contrast, upload-storage.ts on the actual-file-saving side had a function that centralizes building the storage key in one place, along with a comment explaining its design intent.
/**
* ## Per-school object prefix (tenant isolation, PR #516 Reviewer Medium)
* GCS does not honor PostgreSQL RLS. The bucket is a single bucket shared across schools, so
* storage keys must always sit under a **per-school prefix, `uploads/{schoolId}/...`**, so that a
* future IAM condition can draw an access boundary per prefix (laying the groundwork to structurally
* prevent cross-tenant object reads). The tail of the storage key is a **server-generated UUID plus a
* MIME-derived extension** — the client's filename is never used in the path (path traversal prevention,
* NFR03).
*/
export function buildUploadObjectPath(schoolId: string, objectId: string, ext: string): string {
// Throws RangeError if schoolId / objectId contains "/"
return `uploads/${schoolId}/${objectId}.${ext}`;
}
This comment itself states outright that “GCS does not honor PostgreSQL RLS.” The actual-upload side was designed around that premise, enforcing the per-school prefix on the server. But the separate endpoint that only registers metadata never went through buildUploadObjectPath, and simply trusted whatever the client submitted.
Cause
Splitting the actual upload and the metadata registration into separate endpoints was a reasonable design choice on its own (separating the real signed-URL upload from the DB registration is a common pattern). The problem was that validating the tenant boundary — which school’s prefix a storage_path is supposed to sit under — was never explicitly written as either endpoint’s responsibility.
On the actual-upload side, the path is generated server-side, so there’s no room at all to point at another school’s prefix. The “write” side was structurally incapable of this problem. The metadata-registration side, on the other hand, was built on the premise that “this just registers a file that’s already been legitimately uploaded,” so validating the submitted storagePath on format alone (non-empty, length) was considered sufficient.
RLS was functioning as the primary tenant-isolation defense layer, but what RLS protects is PostgreSQL rows, not the legitimacy of the GCS object path string itself. The question “what happens if another school’s prefix is submitted” sat entirely outside what RLS covers, and it fell through the cracks in review on both endpoints.
The fix
A validation function paired with buildUploadObjectPath’s generation convention was added to upload-storage.ts.
/**
* Validates that the submitted `storagePath` falls within the given school's per-school upload
* prefix (prevents cross-tenant registration).
*
* Pairs with the `uploads/{schoolId}/...` convention that `buildUploadObjectPath` generates. Since GCS
* does not honor PostgreSQL RLS, letting a client-supplied object path be registered as an attachment
* as-is opens a path where registering a path under another school's prefix lets that object be read
* cross-tenant via listAttachments / download. This structurally closes that off at registration time by
* restricting to the caller's own school's 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}/`);
}
On the metadata-registration POST handler side, this validation now runs after passing the authorization gate (allowedRoles), using the authenticated user’s user.schoolId.
const row = await withSession(
(tx: TenantTx, user) => {
// Prevent cross-tenant registration: the submitted storagePath must stay within our own
// school's prefix (uploads/{schoolId}/). Validated with user.schoolId after passing the
// authorization gate (allowedRoles) — client-supplied path is never trusted.
if (!isWithinSchoolUploadPrefix(parsed.data.storagePath, user.schoolId)) {
throw new ForbiddenError();
}
return addAttachment(tx, user.uid, id, parsed.data);
},
{ allowedRoles: TEACHER_INPUT_STAFF_ROLES },
);
The schoolId used for validation comes from the authenticated session’s user.schoolId, not the request body. The client only ever submits storagePath — the decision of “which school this belongs to” is left entirely to the server-side session.
The submitted mimeType was also run through the same allowlist used by the actual upload (resolveUploadType), rejecting disallowed formats with 415. That’s a different defense from the tenant boundary, but since it was the same underlying shape of hole — “trusting a client-submitted value and registering it to the DB without validation” — it was closed in the same fix.
The new isWithinSchoolUploadPrefix got unit tests covering: allow within own-school prefix, reject another school’s prefix, reject any prefix other than uploads/, reject .. injection, and reject when schoolId is empty or contains a separator character.
it("rejects a path under another school's prefix (structurally closes off cross-tenant registration)", () => {
const other = "33333333-3333-3333-3333-333333333333";
expect(isWithinSchoolUploadPrefix(`uploads/${other}/obj.pdf`, SCHOOL)).toBe(false);
});
it("rejects .. injection (path traversal)", () => {
expect(isWithinSchoolUploadPrefix(`uploads/${SCHOOL}/../../etc/x`, SCHOOL)).toBe(false);
});
Preventing a repeat
When an endpoint sits next to “a table that’s supposed to be protected by RLS” but accepts data RLS has no jurisdiction over — in this case, an object-storage path string — that endpoint becomes a side door sitting outside RLS’s reach. Adopting RLS itself was functioning correctly for DB rows. What got missed was the check of whether what RLS protects actually lines up with what can actually be read across a tenant boundary.
When working with the same kind of setup (a single bucket, tenant isolation via per-school prefixes), it’s worth auditing not just the side that generates object paths, but every endpoint that accepts an object path from the outside and records it, to confirm the same prefix validation runs there too. The assumption that having a defense on one side is enough is exactly what let this path go unnoticed.
Frequently asked questions
Q1Why did this gap open up when RLS was already in place?
RLS is PostgreSQL row-level access control and has zero effect on Cloud Storage objects. The real-upload endpoint enforced a per-school prefix, uploads/{schoolId}/.... A separate metadata-only endpoint saved the client-supplied storage_path straight to the DB, skipping that mechanism.
Q2What exact path let the cross-tenant read happen?
The teacher-input attachment metadata API validated storagePath and mimeType with zod for format only, then registered them unchecked. A path under another school's uploads/ prefix was accepted as-is, so other tenants' files became readable via listAttachments and download.
Q3Does this problem not occur on the actual-upload side?
No. The actual-upload side generates the storage key server-side as uploads/{schoolId}/{server-generated UUID}.{extension}, leaving the client no room to specify a path. The gap was only in the separate metadata-only registration endpoint.
Q4What validation did the fix add?
A new function, isWithinSchoolUploadPrefix, checks the submitted storage_path falls within the user's own user.schoolId prefix, rejecting with 403 otherwise. mimeType also now runs through the upload's allowlist, resolveUploadType, rejecting unsupported formats with 415.
Q5Why was validation missing on just this one API?
This API assumed 'the client already uploaded via a separate path, this just registers that path' — believing file validity was checked upstream. Validating which school's prefix the path pointed to wasn't explicitly anyone's job, and it fell through the gap.
Environment verified
- Next.js ^16.4.2 / drizzle-orm ^0.45.2 / @google-cloud/storage ^7.19.0 / zod ^4.6.7 / vitest ^4.1.12
- Teacher-input attachment metadata API. Fixed in a PR on 2026-06-21
What this article is based on
- TypeScript file lines 1-51commit 0476129
- TypeScript file lines 1-87commit 0476129
- TypeScript file lines 53-76commit a12b7ef
- TypeScript file lines 1-108commit a12b7ef
- TypeScript file lines 39-65commit a12b7ef
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.