diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index 704f6aeb224..8e0eded2f2e 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -4,7 +4,8 @@ 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( folderId: Folder.FolderId, @@ -21,17 +22,47 @@ 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 + // 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)) { + throw new Error("Folder not found"); + } + } + const rows = isAllSpacesEntry ? await db() .select({ id: sharedVideos.videoId }) .from(sharedVideos) - .where(eq(sharedVideos.folderId, folderId)) + .where( + and( + eq(sharedVideos.folderId, folderId), + eq(sharedVideos.organizationId, user.activeOrganizationId), + ), + ) : 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, 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({