From 97942b3e117f4420c78ccff4acfe0ed6504e44f0 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:45:17 +0900 Subject: [PATCH 1/4] fix(web): verify space membership before listing space and folder videos getSpaceVideoIds and getFolderVideoIds authenticated the caller but never checked whether they belong to the requested space. Both take a caller-supplied spaceId, so any signed-in user could enumerate the video IDs of a space in an organization they are not a member of. Reuse the existing getSpaceAccess helper. The all-spaces branch is left as-is: it keys off user.activeOrganizationId, which comes from the user record rather than the caller, so it is already scoped. --- apps/web/actions/folders/get-folder-videos.ts | 11 +++++++++++ apps/web/actions/spaces/get-space-videos.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index 704f6aeb224..b54d6a448d0 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Folder, Space, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getFolderVideoIds( folderId: Folder.FolderId, @@ -23,6 +24,16 @@ export async function getFolderVideoIds( const isAllSpacesEntry = user.activeOrganizationId === spaceId; + // `spaceId` is caller-supplied. `activeOrganizationId` is read from the + // user record so the all-spaces branch is already scoped to them, but the + // space branch would otherwise expose folder contents of any space. + if (!isAllSpacesEntry) { + const access = await getSpaceAccess(user.id, spaceId); + if (!access || (!access.organizationRole && !access.spaceRole)) { + throw new Error("Folder not found"); + } + } + const rows = isAllSpacesEntry ? await db() .select({ id: sharedVideos.videoId }) diff --git a/apps/web/actions/spaces/get-space-videos.ts b/apps/web/actions/spaces/get-space-videos.ts index ddbcc42b823..4bab6d4ed7c 100644 --- a/apps/web/actions/spaces/get-space-videos.ts +++ b/apps/web/actions/spaces/get-space-videos.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Space } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { try { @@ -20,6 +21,16 @@ export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { const isAllSpacesEntry = user.activeOrganizationId === spaceId; + // `spaceId` comes from the caller, so being signed in is not enough. + // Without this, any authenticated user can enumerate the video IDs of a + // space in an organization they do not belong to. + if (!isAllSpacesEntry) { + const access = await getSpaceAccess(user.id, spaceId); + if (!access || (!access.organizationRole && !access.spaceRole)) { + throw new Error("Space not found"); + } + } + const videoIds = isAllSpacesEntry ? await db() .select({ From 5c58b58f34b68e0d69e30f26afb820c40104c46d Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:55:15 +0900 Subject: [PATCH 2/4] fix(web): scope folder video queries to the authorized space Addresses review feedback: the membership check alone was insufficient because folderId is also caller-supplied. A caller could pass a space they legitimately belong to together with a folder from another space and still read its contents. Constrain both queries to the space (or organization) that was just authorized. --- apps/web/actions/folders/get-folder-videos.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index b54d6a448d0..fed707f8194 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Folder, Space, Video } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getFolderVideoIds( @@ -24,9 +24,15 @@ export async function getFolderVideoIds( const isAllSpacesEntry = user.activeOrganizationId === spaceId; - // `spaceId` is caller-supplied. `activeOrganizationId` is read from the - // user record so the all-spaces branch is already scoped to them, but the - // space branch would otherwise expose folder contents of any space. + // `spaceId` is caller-supplied, so the space branch needs a membership + // check. The all-spaces branch compares against `activeOrganizationId`, + // which is read from the user record, so it is already scoped. + // + // The membership check alone is not sufficient: `folderId` is also + // caller-supplied, so the queries below must be constrained to the space + // (or org) we just authorized. Otherwise a caller could pass a space they + // legitimately belong to together with a folder from another space and + // still read its contents. if (!isAllSpacesEntry) { const access = await getSpaceAccess(user.id, spaceId); if (!access || (!access.organizationRole && !access.spaceRole)) { @@ -38,11 +44,21 @@ export async function getFolderVideoIds( ? await db() .select({ id: sharedVideos.videoId }) .from(sharedVideos) - .where(eq(sharedVideos.folderId, folderId)) + .where( + and( + eq(sharedVideos.folderId, folderId), + eq(sharedVideos.organizationId, spaceId), + ), + ) : await db() .select({ id: spaceVideos.videoId }) .from(spaceVideos) - .where(eq(spaceVideos.folderId, folderId)); + .where( + and( + eq(spaceVideos.folderId, folderId), + eq(spaceVideos.spaceId, spaceId), + ), + ); return { success: true, From 26d47dc7a8d1716c5c19f92bec3e9f66275f25bf Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:00:13 +0900 Subject: [PATCH 3/4] fix(web): validate spaceId up front in getFolderVideoIds Addresses review feedback: an empty spaceId previously fell through to getSpaceAccess and surfaced a misleading "Folder not found". Matches the validation getSpaceVideoIds already does. --- apps/web/actions/folders/get-folder-videos.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index fed707f8194..dc54ab48fda 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -22,6 +22,10 @@ export async function getFolderVideoIds( throw new Error("Folder ID is required"); } + if (!spaceId) { + throw new Error("Space ID is required"); + } + const isAllSpacesEntry = user.activeOrganizationId === spaceId; // `spaceId` is caller-supplied, so the space branch needs a membership From 02ecc47f66a7e0093aa73e41acb4c53dd7b2fce9 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:06:59 +0900 Subject: [PATCH 4/4] refactor(web): scope the all-spaces folder query by the trusted org id Addresses review feedback: in that branch spaceId equals user.activeOrganizationId by construction, so using the latter makes it explicit that the query is scoped by a server-controlled value. --- apps/web/actions/folders/get-folder-videos.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index dc54ab48fda..8e0eded2f2e 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -51,7 +51,7 @@ export async function getFolderVideoIds( .where( and( eq(sharedVideos.folderId, folderId), - eq(sharedVideos.organizationId, spaceId), + eq(sharedVideos.organizationId, user.activeOrganizationId), ), ) : await db()