Skip to content

fix(web): gate /api/video/transcribe/status on video access policy#2032

Open
DPS0340 wants to merge 4 commits into
CapSoftware:mainfrom
DPS0340:fix/transcribe-status-access-check
Open

fix(web): gate /api/video/transcribe/status on video access policy#2032
DPS0340 wants to merge 4 commits into
CapSoftware:mainfrom
DPS0340:fix/transcribe-status-access-check

Conversation

@DPS0340

@DPS0340 DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown

Problem

/api/video/transcribe/status authenticates the caller and then trusts the videoId query parameter:

const user = await getCurrentUser();
const videoId = url.searchParams.get("videoId") as Video.VideoId;

if (!user) return Response.json({ auth: false }, { status: 401 });

const video = await db().select().from(videos).where(eq(videos.id, videoId));
// ...returns video[0].transcriptionStatus

Being signed in is the only barrier, so any authenticated user can read the transcription status of any video by ID — including private recordings belonging to someone else. It also doubles as an existence oracle, since a missing video returns 404 while an existing one returns a status.

The clearest evidence that this is a gap rather than intentional: the getVideoStatus server action returns this exact same field and already gates on VideosPolicy.canView (apps/web/actions/videos/get-status.ts). Two entry points to the same data, only one of them checked.

Fix

Apply the same policy the server action uses, folded onto the query that was already being made — no extra round-trip:

const exit = await Effect.gen(function* () {
  const videosPolicy = yield* VideosPolicy;
  return yield* Effect.promise(() =>
    db().select().from(videos).where(eq(videos.id, videoId)),
  ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));
}).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit);

if (Exit.isFailure(exit)) {
  return Response.json({ error: true, message: "Video does not exist" }, { status: 404 });
}

Reusing canView keeps every legitimate caller working — owners, org members, space members, public videos, and password-protected videos with a valid cookie.

The denial response deliberately reuses the existing "Video does not exist" 404 rather than a distinct 403, so an unauthorized caller cannot distinguish "exists but private" from "does not exist".

Verification

  • biome check — clean.
  • tsc --noEmit on apps/web: the TS2488 / TS18046 diagnostics on this file are artifacts of the Effect generator pattern under an unbuilt workspace, not regressions — the untouched actions/videos/get-status.ts, which uses the identical pattern, emits the same pair. Verified by diffing the two files' diagnostics.
  • @cap/web-domain, @cap/web-backend, @cap/database build clean.

Diff: 1 file, +21/-2.

Same class of missing access check as #2029 (newComment, closes #1982), #2030 (getVideoAnalytics), and #2031 (space/folder video listing). This one is the last videoId-taking endpoint I found without a check.

The route authenticated the caller but then looked the video up by the
caller-supplied videoId with no access check, so any signed-in user could
read the transcription status of another user's private recording.

The getVideoStatus server action returns this same field and already
gates on VideosPolicy.canView; apply the same policy here.
const videosPolicy = yield* VideosPolicy;

return yield* Effect.promise(() =>
db().select().from(videos).where(eq(videos.id, videoId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor perf: since videos.id is unique, consider adding .limit(1) so the DB can stop early.

Suggested change
db().select().from(videos).where(eq(videos.id, videoId)),
return yield* Effect.promise(() =>
db().select().from(videos).where(eq(videos.id, videoId)).limit(1),
).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));

).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));
}).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit);

if (Exit.isFailure(exit)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Exit.isFailure(exit) can also represent unexpected errors (DB connectivity, etc.). If you want to keep the 404-for-deny behavior to avoid an existence oracle, it might be worth distinguishing policy denial from other failures (and logging/returning 500 for the latter) so real outages aren't silently masked.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I was wrong to skip this one, and re-checking it showed the case is real. Adopted in 236db3478.

Why my first answer was wrong. I'd argued the failure channel here was effectively only PolicyDenied, so collapsing everything to 404 was safe. It isn't. canView reads through VideosRepo.getById, spacesRepo.passwordsForVideo, orgsRepo.membershipForVideo and orgsRepo.allowedEmailDomain — all of which go through Database.use, which wraps failures as DatabaseError:

// packages/web-backend/src/Database.ts
use: <T>(cb) => Effect.tryPromise({ try: () => cb(db()), catch: (cause) => new DatabaseError({ cause }) })

So DatabaseError shares the failure channel with PolicyDeniedError, and my code mapped both to 404 Video does not exist. During a database blip, a user asking about a video they genuinely own gets told it doesn't exist, and nothing is logged.

The fix keys off the tag rather than treating every failure alike, using the same Schema.is narrowing OrganisationsRpcs.ts uses for error mapping:

if (Option.isSome(failure) && Schema.is(DatabaseError)(failure.value))  500 + console.error
otherwise                                                              404

I kept the 404-for-denied behaviour, since as you noted that's the existence-oracle defence and it's worth keeping.

Proof it bites, rather than just asserting it. Added __tests__/unit/transcribe-status-access.test.ts (6 cases: 401, 400, denial→404, DB failure→500, success, missing row→404). All 6 pass with the fix. Reverting only route.ts to the previous commit and re-running:

× returns 500, not 404, when the access check hits a database failure
  Tests  1 failed | 5 passed (6)

Exactly the one case, so the test is pinned to this behaviour and not to incidental structure.

tsc --noEmit clean for both files, biome check clean.

Note on verifying this locally: tsc in apps/web reports TS6305 for every workspace import until npx tsc --build packages/web-domain packages/web-backend packages/database has been run — worth knowing before trusting a "type error" in this app.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

@tembo please review

Addresses review feedback: videos.id is unique so the DB can stop early.
@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Took one of the two, and want to explain why I skipped the other rather than silently ignoring it.

Adopted — .limit(1). Pushed. videos.id is unique so there's no reason to let the DB keep scanning, and /api/video/ai already does exactly this.

Not adopted — distinguishing PolicyDenied from infrastructure failures. The concern is legitimate in the abstract: a DB outage currently surfaces as a 404, which does mask a real problem.

But I checked how this repo actually handles it, and no API route collapsing an Exit from a canView gate makes that distinction today:

route Cause/PolicyDenied handling
/api/video/ai none — Exit.isFailure(exit) → 404
/api/thumbnail none
/api/analytics none

/api/video/ai is the closest precedent, since it's the fix the maintainers wrote for the sibling advisory GHSA-5m2p-v3wg-3gm6 — and my code is currently line-for-line the same shape as theirs. Splitting the cause here would make this one route diverge from the three around it, in a PR whose purpose is closing an access-control gap.

So I'd rather not smuggle an error-handling convention change into a security fix. If you do want the distinction, it seems like it should land as its own change across all four routes at once — happy to open that separately if you're interested, but I didn't want to decide that unilaterally inside this PR.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

@tembo please re-review

Same redundancy tembo flagged on CapSoftware#2029: the route already requires auth
and holds the user, so provideOptionalAuth would re-run getServerSession()
and re-query users to build the same layer.
@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Applied the makeCurrentUserLayer feedback from #2029 here too, pre-emptively — this route has the identical redundancy: it already calls getCurrentUser() and returns 401 without a user, then used provideOptionalAuth, which re-runs getServerSession() and re-queries users to build the same layer.

Also dropped the three TS18046 'video' is of type 'unknown' diagnostics as a side effect, since providing the layer concretely narrows the env type.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

@tembo please re-review

1 similar comment
@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

@tembo please re-review

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

@tembo please re-review

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.

Authenticated Users Can Post Comments on Private Videos Without Access Permission

1 participant