fix(web): gate getVideoAnalytics server action on video access policy#2030
fix(web): gate getVideoAnalytics server action on video access policy#2030DPS0340 wants to merge 3 commits into
Conversation
The /api/analytics route already checks VideosPolicy.canView, but getVideoAnalytics is a "use server" action and is therefore directly callable by any client without going through that route. It performed no auth of any kind, so view counts for private and password-protected videos were readable by anyone holding a video ID. Apply the same canView gate inside the action, reusing the query that already fetches orgId so no extra round-trip is added.
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(id))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); | ||
|
|
||
| if (Exit.isFailure(exit)) throw new Error("Video not found"); |
There was a problem hiding this comment.
If the query returns an empty array (video missing), this still proceeds and queries Tinybird without a tenant filter. Might be safer to treat that as not found too.
| if (Exit.isFailure(exit)) throw new Error("Video not found"); | |
| if (Exit.isFailure(exit) || exit.value.length === 0) throw new Error("Video not found"); |
|
@tembo please review |
Addresses review feedback: an empty result previously fell through and queried Tinybird without a tenant filter.
|
Adopted — pushed. You're right, and it's slightly worse than just "safer to treat as not found": with an empty result orgId ? `tenant_id = '${escapeLiteral(orgId)}'` : undefinedso a Now returns not-found before reaching Tinybird, with a comment explaining why the early return is load-bearing rather than a tidiness thing. |
|
@tembo please re-review |
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(id))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); | ||
|
|
||
| // An empty result means the video does not exist. Falling through would |
There was a problem hiding this comment.
The tenant-filter rationale in this comment feels a bit off since pathname already scopes by videoId. Might be clearer to just frame this as avoiding querying analytics for non-existent/unauthorized videos.
| // An empty result means the video does not exist. Falling through would | |
| // If the video doesn't exist or isn't viewable, don't query Tinybird. | |
| // Otherwise we'd return analytics for an arbitrary `/s/${videoId}` pathname. |
Addresses review feedback: pathname already scopes by videoId, so the earlier 'widens beyond a single org' framing overstated it.
|
You're right, and I've taken your wording — pushed. Checking my own claim: The early return is still worth having for exactly the reason you framed it, so the behaviour is unchanged — only the comment was wrong. |
|
@tembo please re-review |
| .from(videos) | ||
| .where(eq(videos.id, Video.VideoId.make(videoId))) | ||
| .limit(1); | ||
| const id = Video.VideoId.make(videoId); |
There was a problem hiding this comment.
Minor edge case: Video.VideoId.make(videoId) can throw on malformed IDs; might be worth mapping that to the same not-found error so clients don’t get a 500 for invalid input.
| const id = Video.VideoId.make(videoId); | |
| const id = (() => { | |
| try { | |
| return Video.VideoId.make(videoId); | |
| } catch { | |
| throw new Error("Video not found"); | |
| } | |
| })(); |
|
I checked this one and I don't think the edge case exists — happy to be corrected if I've misread it.
// packages/web-domain/src/Video.ts:13
export const VideoId = Schema.String.pipe(Schema.brand("VideoId"));There are no refinements attached (no length, no pattern), so All pass, none throw. So a try/catch there would be dead code today. It would become necessary the moment Leaving it as-is for now. If you'd like the branded type tightened I'm happy to open that separately, since it'd touch every call site and shouldn't ride along in an access-control fix. |
|
@tembo please re-review |
Problem
/api/analytics/route.tscorrectly gates onVideosPolicy.canViewbefore returning view counts — the comment there even says it exists so that "view counts of private / password-protected videos are not disclosed to unauthorized callers."But
getVideoAnalyticsis a"use server"action, so it is its own entry point. Any client can invoke it directly with an arbitraryvideoIdand never touch that route. The action itself performed no authentication at all — it contains zero calls togetCurrentUser,provideOptionalAuth, or any policy:So the protection on the route is bypassable, and view counts for private and password-protected videos are readable by anyone who knows (or guesses) a video ID. It's the same class of gap as #1982 and the
/api/video/aiadvisory fixed in #1926: the check lives at one caller instead of at the boundary that actually needs it.The action is already called directly from client code today —
Analytics.tsx(a"use client"component) calls it in auseEffect— which is what makes it reachable rather than theoretical.Fix
Move the gate into the action, using the same
canViewpolicy the route uses.The action already had to query
videosto fetchorgId, so I folded the policy onto that existing query rather than adding a second round-trip:Reusing
canViewmeans public videos, owners, org members, space members, and password-protected videos with a valid cookie all keep working exactly as before — the legitimate paths inpage.tsxandAnalytics.tsxare unaffected. Only unauthorized cross-user reads change behaviour.The redundant check in
/api/analyticsis left in place: it returns a clean404there, and defence in depth at the route costs nothing.Verification
biome check apps/web/actions/videos/get-analytics.ts— clean.tsc --noEmitonapps/web: theTS2347on this file is pre-existing — I confirmed by stashing the change and re-running, where it appears identically (at the old line number). TheTS6305/TS2488/TS18046diagnostics are project-reference artifacts that the untouchedget-transcript.tsemits too.@cap/web-domain,@cap/web-backend,@cap/databasebuild clean.Diff: 1 file, +25/-8.
Related: #2029 fixes the same class of issue in
newComment(#1982).