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 |
|
@richiemcilroy — your own #1928 already covers this, and it covers more. Please take that one over this. I missed it when I opened this. #1928 (19 June, yours) touches both files I touch here and four more: Same approach, too — gate on There is nothing in mine worth salvaging on top of yours. Closing this in favour of #1928 unless you want it kept as a fallback. Two more of mine overlap open work, which I should have checked before opening any of them:
The check I skipped is cheap and I'm sorry for the noise: for each file a PR touches, list open PRs touching the same file. I've now run it across all nine of mine — the other six (#2030, #2032, #2034, #2035, #2036, #2038) have no functional overlap with anything open. |
|
Closing as promised — #1928 (yours) covers this and got there first. No action needed from anyone; I'm removing it from the review queue rather than leaving it sitting as something you'd have to diff and dismiss. Nothing here is lost: if any piece turns out to be worth having after #1928 (yours) lands, I'll send it as a small follow-up against that. |
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).