Skip to content

fix(web): add ownership checks to folder, space and org video actions#1928

Open
richiemcilroy wants to merge 3 commits into
mainfrom
security/folder-space-idor
Open

fix(web): add ownership checks to folder, space and org video actions#1928
richiemcilroy wants to merge 3 commits into
mainfrom
security/folder-space-idor

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jun 19, 2026

Copy link
Copy Markdown
Member

Adds ownership and membership checks to the folder, space and organization video actions so a user can only move, add to, or list videos they actually have access to.

Greptile Summary

This PR adds ownership and membership gates to six server actions and one page that previously disclosed or mutated video/folder data without verifying whether the caller actually had access. The approach — creator-only for personal folders, role-check via getSpaceAccess/getOrganizationAccess for space/org-backed folders — is consistent across all changed files and correctly mirrors the policy in FoldersPolicy.

  • moveVideoToFolder.ts correctly scopes the destination-folder lookup to user.activeOrganizationId and adds eq(videos.ownerId, user.id) to the personal-move UPDATE.
  • get-organization-videos.ts and get-space-videos.ts add the expected membership guards and correctly distinguish the all-spaces (org-ID) case from a real space ID.
  • add-videos.ts and get-folder-videos.ts introduce new folder lookups but omit eq(folders.organizationId, user.activeOrganizationId) from the WHERE clause, unlike the equivalent lookup in moveVideoToFolder.ts — leaving a cross-org boundary gap.

Confidence Score: 4/5

Mostly safe, but two of the six changed files have a cross-org boundary gap in their new folder lookups that should be patched before merging.

The folder lookups added in add-videos.ts and get-folder-videos.ts do not constrain the query to user.activeOrganizationId, unlike the equivalent lookup in moveVideoToFolder.ts in the same PR. A user who created or has manage access to a folder in another organisation can pass that foreign folder ID, pass the ownership/role check, and either probe cross-org folder existence or insert sharedVideos/spaceVideos rows in the current org that reference a foreign folder's primary key.

apps/web/actions/folders/add-videos.ts and apps/web/actions/folders/get-folder-videos.ts — both need eq(folders.organizationId, user.activeOrganizationId) added to their new folder WHERE clauses.

Security Review

  • Cross-org folder ID targeting (add-videos.ts, get-folder-videos.ts): Both new folder lookups are missing eq(folders.organizationId, user.activeOrganizationId). A user who created (or has canManage access to) a folder in a different organisation can pass that foreign folderId, pass the ownership/role check, and either probe folder existence across org boundaries (get-folder-videos.ts) or write sharedVideos/spaceVideos rows for the current org that carry a cross-org folderId FK (add-videos.ts). moveVideoToFolder.ts in the same PR correctly scopes its lookup with the org constraint.

Important Files Changed

Filename Overview
apps/web/actions/folders/add-videos.ts Adds folder ownership gate, but folder lookup lacks org scope — a user who created a folder in another org can target it cross-org, producing corrupt FK references in sharedVideos/spaceVideos.
apps/web/actions/folders/get-folder-videos.ts Adds pre-check before disclosing folder contents, but the folder lookup is also unscoped to the caller's org, enabling cross-org folder probing via a valid folderId from another organization.
apps/web/actions/folders/moveVideoToFolder.ts Correctly adds space/org permission checks per branch and adds ownerId guard to the personal-move UPDATE; destination folder lookup is properly scoped to user.activeOrganizationId.
apps/web/actions/organizations/get-organization-videos.ts Adds getOrganizationAccess membership check before listing org videos; straightforward and correct.
apps/web/actions/spaces/get-space-videos.ts Correctly routes to getOrganizationAccess for the all-spaces (org-ID) case and getSpaceAccess with a role-presence guard for actual space IDs.
apps/web/app/(org)/dashboard/folder/[id]/page.tsx Pre-flight access check correctly uses Effect.catchAll to surface missing folders as notFound() instead of a 500; mirrors the access policy used in the server actions.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/web/actions/folders/add-videos.ts:33-40
The folder lookup is not scoped to `user.activeOrganizationId`. In `moveVideoToFolder.ts` the equivalent query correctly adds `eq(folders.organizationId, user.activeOrganizationId)` to the WHERE clause. Without it, a caller who created a personal folder in a different organisation (or holds `canManage` over a space in another org) can pass that foreign `folderId`, pass the ownership check, and the subsequent inserts will write `sharedVideos`/`spaceVideos` rows for the current org that carry a `folderId` pointing to another org's folder — corrupting cross-org FK references.

```suggestion
		const [folder] = await db()
			.select({
				id: folders.id,
				spaceId: folders.spaceId,
				createdById: folders.createdById,
			})
			.from(folders)
			.where(
				and(
					eq(folders.id, folderId),
					eq(folders.organizationId, user.activeOrganizationId),
				),
			);
```

### Issue 2 of 2
apps/web/actions/folders/get-folder-videos.ts:25-33
Same org-scoping omission as `add-videos.ts`. The folder lookup here is also missing `eq(folders.organizationId, ...)`. Without it, a user who owns (or has space access to) a folder in another organisation can probe whether any given UUID is a valid folder across the entire system, and the access-check logic will evaluate the foreign folder's `spaceId`/`createdById` instead of rejecting it outright. Adding the org constraint is the consistent fix mirroring `moveVideoToFolder.ts`.

```suggestion
		// Ensure the caller can see this folder before disclosing its contents.
		const [folder] = await db()
			.select({
				spaceId: folders.spaceId,
				organizationId: folders.organizationId,
				createdById: folders.createdById,
			})
			.from(folders)
			.where(
				and(
					eq(folders.id, folderId),
					eq(folders.organizationId, user.activeOrganizationId),
				),
			);
```

Reviews (4): Last reviewed commit: "fix(web): validate destination folder ma..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@polarityinc

polarityinc Bot commented Jun 19, 2026

Copy link
Copy Markdown

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

Comment thread apps/web/actions/folders/get-folder-videos.ts
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jun 19, 2026
Comment thread apps/web/actions/folders/get-folder-videos.ts Outdated
Comment thread apps/web/app/(org)/dashboard/folder/[id]/page.tsx
Comment thread apps/web/actions/folders/moveVideoToFolder.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

Comment thread apps/web/actions/folders/get-folder-videos.ts
Comment thread apps/web/actions/folders/add-videos.ts Outdated
Comment thread apps/web/actions/folders/add-videos.ts Outdated
Comment thread apps/web/app/(org)/dashboard/folder/[id]/page.tsx Outdated
Comment thread apps/web/app/(org)/dashboard/folder/[id]/page.tsx Outdated
@superagent-security superagent-security Bot removed pr:flagged PR flagged for review by security analysis. labels Jun 19, 2026
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

Comment thread apps/web/actions/folders/moveVideoToFolder.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

import type { Folder, Space, Video } from "@cap/web-domain";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is to allow regular org members to move their own shared videos from the “All Spaces” view, this import can switch to getOrganizationAccess (and drop the settings-manager requirement below).

Suggested change
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
import { getOrganizationAccess } from "@/actions/organization/authorization";

Comment on lines 86 to 107
@@ -75,12 +106,21 @@ export async function moveVideoToFolder({
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireOrganizationSettingsManager makes this path admin/owner-only, but the underlying UPDATE currently isn’t scoped to the caller’s own videos. That combination blocks normal members and still lets admins move anyone’s shared videos.

Consider using org membership + scoping the UPDATE to sharedByUserId so members can only move videos they shared.

Suggested change
} else if (spaceId && isAllSpacesEntry) {
const access = await getOrganizationAccess(user.id, user.activeOrganizationId);
if (!access) {
throw new Error("Organization not found");
}
// The destination must be an org-level (non-space) folder.
if (destinationFolder && destinationFolder.spaceId !== null) {
throw new Error("Folder not found or not accessible");
}
await db()
.update(sharedVideos)
.set({
folderId: folderId === null ? null : folderId,
})
.where(
and(
eq(sharedVideos.videoId, videoId),
eq(sharedVideos.organizationId, user.activeOrganizationId),
eq(sharedVideos.sharedByUserId, user.id),
),
);

Comment on lines 33 to 40
const [folder] = await db()
.select({ id: folders.id, spaceId: folders.spaceId })
.select({
id: folders.id,
spaceId: folders.spaceId,
createdById: folders.createdById,
})
.from(folders)
.where(eq(folders.id, folderId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security The folder lookup is not scoped to user.activeOrganizationId. In moveVideoToFolder.ts the equivalent query correctly adds eq(folders.organizationId, user.activeOrganizationId) to the WHERE clause. Without it, a caller who created a personal folder in a different organisation (or holds canManage over a space in another org) can pass that foreign folderId, pass the ownership check, and the subsequent inserts will write sharedVideos/spaceVideos rows for the current org that carry a folderId pointing to another org's folder — corrupting cross-org FK references.

Suggested change
const [folder] = await db()
.select({ id: folders.id, spaceId: folders.spaceId })
.select({
id: folders.id,
spaceId: folders.spaceId,
createdById: folders.createdById,
})
.from(folders)
.where(eq(folders.id, folderId));
const [folder] = await db()
.select({
id: folders.id,
spaceId: folders.spaceId,
createdById: folders.createdById,
})
.from(folders)
.where(
and(
eq(folders.id, folderId),
eq(folders.organizationId, user.activeOrganizationId),
),
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/folders/add-videos.ts
Line: 33-40

Comment:
The folder lookup is not scoped to `user.activeOrganizationId`. In `moveVideoToFolder.ts` the equivalent query correctly adds `eq(folders.organizationId, user.activeOrganizationId)` to the WHERE clause. Without it, a caller who created a personal folder in a different organisation (or holds `canManage` over a space in another org) can pass that foreign `folderId`, pass the ownership check, and the subsequent inserts will write `sharedVideos`/`spaceVideos` rows for the current org that carry a `folderId` pointing to another org's folder — corrupting cross-org FK references.

```suggestion
		const [folder] = await db()
			.select({
				id: folders.id,
				spaceId: folders.spaceId,
				createdById: folders.createdById,
			})
			.from(folders)
			.where(
				and(
					eq(folders.id, folderId),
					eq(folders.organizationId, user.activeOrganizationId),
				),
			);
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant