fix(web): verify space membership before listing space and folder videos#2031
fix(web): verify space membership before listing space and folder videos#2031DPS0340 wants to merge 4 commits into
Conversation
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.
| // 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)) { |
There was a problem hiding this comment.
Good hardening. One concern: this new access check keys off the caller-supplied spaceId, but the actual query below only filters by folderId (no spaceId / org constraint). If someone can obtain/guess a folderId from another space/org, they could pass a space they do have access to and still enumerate that folder’s video IDs. Might be worth also scoping the query by spaceVideos.spaceId in the space branch, and by sharedVideos.organizationId (ideally user.activeOrganizationId, not spaceId) in the all-spaces branch.
|
@tembo please review |
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.
|
Good catch — that was a real hole in my fix, not a nitpick. Pushed You're right that the membership check alone was insufficient: Both queries are now constrained to the space/org that was just authorized: // space branch
.where(and(eq(spaceVideos.folderId, folderId), eq(spaceVideos.spaceId, spaceId)))
// all-spaces branch
.where(and(eq(sharedVideos.folderId, folderId), eq(sharedVideos.organizationId, spaceId)))On your I also checked Updated the comment to explain both layers, since the reason the query scoping is load-bearing isn't obvious from the check alone. |
|
@tembo please re-review |
| @@ -23,15 +24,41 @@ export async function getFolderVideoIds( | |||
|
|
|||
| const isAllSpacesEntry = user.activeOrganizationId === spaceId; | |||
There was a problem hiding this comment.
Minor: consider validating spaceId up front (like getSpaceVideoIds), so a missing/empty value doesn't fall through to getSpaceAccess and return a misleading "Folder not found".
| const isAllSpacesEntry = user.activeOrganizationId === spaceId; | |
| if (!spaceId) { | |
| throw new Error("Space ID is required"); | |
| } |
Addresses review feedback: an empty spaceId previously fell through to getSpaceAccess and surfaced a misleading "Folder not found". Matches the validation getSpaceVideoIds already does.
|
Adopted — pushed. You're right that it produced a misleading error: an empty |
|
@tembo please re-review |
| .where( | ||
| and( | ||
| eq(sharedVideos.folderId, folderId), | ||
| eq(sharedVideos.organizationId, spaceId), |
There was a problem hiding this comment.
Small readability/type-safety thing: in this branch we already know spaceId === user.activeOrganizationId, so using user.activeOrganizationId here makes it explicit we're scoping by a trusted org id (and avoids leaning on the caller-supplied arg in the query).
| eq(sharedVideos.organizationId, spaceId), | |
| eq(sharedVideos.organizationId, user.activeOrganizationId), |
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.
|
Adopted — pushed. This is the same point I raised in my earlier reply and you're right that explicit is better: in that branch That leaves a clean split in the file: the space branch scopes by the
|
|
@tembo please re-review |
Problem
getSpaceVideoIdsandgetFolderVideoIdsare both"use server"actions that authenticate the caller and then trust thespaceIdargument:In the
elsebranch there is no membership check at all. Any authenticated user can pass an arbitraryspaceIdand get back the list of video IDs in a space belonging to an organization they have nothing to do with.getFolderVideoIdshas the identical shape for folder contents.Video IDs are the sensitive part here — they're the key into
/s/{videoId}and into the other video endpoints, so leaking the set of IDs in a private space is a meaningful disclosure even before any per-video check runs.Fix
Reuse the existing
getSpaceAccesshelper from@/actions/organization/space-authorization, which already resolves both organization-level and space-level roles:Two deliberate choices:
getSpaceAccess, notrequireSpaceManager. These are read paths.requireSpaceManagerwould have rejected ordinary members, breaking legitimate use. Requiring either an org role or a space role matches who can actually see a space today.isAllSpacesEntrybranch is left untouched. It keys offuser.activeOrganizationId, which is read from the user's own DB record rather than supplied by the caller, so that branch is already scoped correctly. Adding a check there would have been noise.Error text is
"Space not found"/"Folder not found"rather than "forbidden", so this doesn't become a probe for which space IDs exist.Verification
biome checkon both files — clean.tsc --noEmitonapps/web: theTS7006diagnostics on these two files are pre-existing. Confirmed by stashing the change and re-running — they appear identically, just at the earlier line numbers (38→49, 46→57). No new diagnostics.@cap/web-domain,@cap/web-backend,@cap/databasebuild clean.Diff: 2 files, +20/-0.
Related, same class of missing access check: #2029 (
newComment, #1982) and #2030 (getVideoAnalytics).