Skip to content

Missing access checks on caller-supplied video/space IDs (4 endpoints, PRs open) #2033

Description

@DPS0340

While working on #1982 I audited every entry point in apps/web that accepts a caller-supplied resource ID, since that advisory looked like an instance of a pattern rather than a one-off. It was — I found four, and opened a PR for each.

The pattern

Cap has a solid access-control primitive in VideosPolicy.canView, and most code uses it. The failures all have the same shape: a second entry point to the same data that skips the check. Server actions are the main way this happens, because a "use server" function is independently callable by any client and does not inherit whatever gating its usual API route does.

What I found

PR Entry point State before Impact
#2029 newComment action authenticated only write comments/reactions into anyone's private video, notifying the owner
#2030 getVideoAnalytics action no auth at all read view counts of any private video
#2031 getSpaceVideoIds / getFolderVideoIds authenticated only enumerate video IDs in spaces of orgs you aren't in
#2032 /api/video/transcribe/status authenticated only read transcription status of anyone's private video

Two are worth calling out specifically:

#2030 is the sharpest. /api/analytics/route.ts does gate on canView, with a comment stating it exists so private view counts aren't disclosed. But getVideoAnalytics is a server action with zero auth of any kind — no getCurrentUser, no policy — and a "use client" component (Analytics.tsx) already calls it directly. The route's protection is bypassable by calling the action the same way the app's own client code does.

#2031 leaks IDs, which are the key to everything else. Video IDs are what /s/{videoId} and the other video endpoints take, so enumerating a private space's ID set is useful to an attacker even before any per-video check runs.

Sweep result

After these four, I re-swept: no remaining videoId-taking server action or API route in apps/web lacks an access check. Two files still show up in a naive grep but are fine on inspection — /api/notifications takes no ID and only returns the caller's own rows, and /api/tools/loom-download takes a Loom video ID, not a Cap resource.

Notes on the fixes

  • Every fix reuses an existing primitive (VideosPolicy.canView, or getSpaceAccess for the space paths) rather than introducing new logic, so they inherit the full existing model — owner, org, space, public, password, email-domain restrictions.
  • For fix(web): verify space membership before listing space and folder videos #2031 I used getSpaceAccess and not requireSpaceManager: these are read paths, and requiring manager would have locked out ordinary members.
  • Denials return "not found" rather than "denied" throughout, so none of them become an oracle for which IDs exist.
  • Each PR is 1–2 files. They're independent and can be merged in any order.

Happy to consolidate them into a single PR if you'd prefer to review it that way.

cc @richiemcilroy

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions