fix(web): enforce video access policy before creating comments (#1982)#2029
fix(web): enforce video access policy before creating comments (#1982)#2029DPS0340 wants to merge 3 commits into
Conversation
The newComment server action authenticated the caller but never checked whether they may access the target video. Since videoId comes from the client, any signed-in user could insert comments into another user's private recording. Gate the insert behind VideosPolicy.canView, matching the pattern already used by getTranscript and the /api/video/ai fix in CapSoftware#1926.
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); | ||
|
|
||
| if (Exit.isFailure(accessExit) || accessExit.value.length === 0) { |
There was a problem hiding this comment.
Minor: splitting the Exit.isFailure check from the .value access lets the type guard narrow cleanly (and matches the pattern used in get-transcript.ts).
| if (Exit.isFailure(accessExit) || accessExit.value.length === 0) { | |
| if (Exit.isFailure(accessExit)) { | |
| throw new Error("Video not found"); | |
| } | |
| if (accessExit.value.length === 0) { | |
| throw new Error("Video not found"); | |
| } |
|
@tembo please review |
Addresses review feedback: separating Exit.isFailure from the .value access lets the type guard narrow cleanly and matches get-transcript.ts. Also limits the lookup to one row since videos.id is unique.
|
Adopted — pushed. Split the branches so the type guard narrows cleanly, matching |
|
@tembo please re-review |
| import { comments } from "@cap/database/schema"; | ||
| import { comments, videos } from "@cap/database/schema"; | ||
| import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; | ||
| import type { ImageUpload } from "@cap/web-domain"; |
There was a problem hiding this comment.
Minor perf/consistency nit: provideOptionalAuth will call getServerSession() + query users again. Since this action already requires auth and you already have user, you can provide the current-user layer directly and skip the extra DB hit.
| import type { ImageUpload } from "@cap/web-domain"; | |
| import { makeCurrentUserLayer, VideosPolicy } from "@cap/web-backend"; |
| .limit(1), | ||
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); | ||
|
|
There was a problem hiding this comment.
Following on the above import, this avoids re-fetching the current user inside the Effect env.
| }).pipe(Effect.provide(makeCurrentUserLayer(user)), EffectRuntime.runPromiseExit); |
Addresses review feedback: newComment already requires auth and holds the user, so provideOptionalAuth would redundantly call getServerSession() and re-query users. Provide the current-user layer directly.
|
Adopted — pushed. Verified the redundancy before changing it: const user = yield* getCurrentUser; // getServerSession() + select from users
return yield* user.pipe(Option.match({
onNone: () => app,
onSome: (user) => app.pipe(Effect.provide(makeCurrentUserLayer(user))),
}));so on a path that has already established Now Small bonus: this also removed a |
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.
|
@tembo please re-review |
1 similar comment
|
@tembo please re-review |
|
@richiemcilroy — nine PRs from me are sitting green and I don't want them to read as a wall, so here they are ordered by review cost. Every one is Cheapest three — one file each, no behavioural surface beyond the fix:
The access-policy set — these are one theme, and reviewing #2029 first makes the rest fast:
Two standalone:
Two notes so review time goes to the code and not to context:
No rush on any individual one — happy for you to take the three cheap ones and leave the rest, if that's the useful split. |
|
@richiemcilroy — #2021 already fixes this, and it got there first. Please review that one instead of this. I opened this on 25 July without noticing @eponomariova-dotcom's PR from 20 July. They are the same fix in the same file, and theirs is the more finished one:
Their version also independently caught the subtlety I did — The only real difference left is I'll close this in favour of #2021 unless you'd rather I keep it open as a fallback — say the word and it's gone. Sorry for the duplicated review surface. I checked #1982 for existing linked PRs but not for open PRs referencing it, which is the check that would have caught this before I wrote anything. My other eight PRs don't overlap #2021 — I verified each touches a different file. |
|
Closing as promised — #2021 by @eponomariova-dotcom 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 #2021 by @eponomariova-dotcom lands, I'll send it as a small follow-up against that. |
Fixes #1982 (GHSA-5r3r-v2f5-wqwh).
Problem
The
newCommentserver action authenticates the caller, then inserts the comment without ever checking whether that user is allowed to access the target video:videoIdis a client-supplied argument to a server action, so being signed in is the only barrier. Any authenticated user can post comments — or emoji reactions, or threaded replies — into any video by ID, including another user's private recording. The comment then shows up in the owner's activity feed and triggers a notification to them viacreateNotification.Worth noting the same file already treats deletion correctly:
deleteCommentverifiesauthorIdbefore removing anything. Creation was the gap.Fix
Gate the insert behind the existing
VideosPolicy.canViewpolicy before any write happens.I deliberately did not invent a new access-check shape — this mirrors the pattern already used in this exact directory by
getTranscript(apps/web/actions/videos/get-transcript.ts), and it is the same policy the/api/video/aifix in #1926 used to close the sibling advisory #1981:Because it reuses
canView, it inherits the full existing access model for free — owner, org membership, space membership, public videos, password-protected videos, and email-domain restrictions — rather than approximating it with an ownership check that would have broken legitimate commenting on shared videos.The error message is deliberately
"Video not found"rather than"Access denied", so this does not become an oracle for probing which video IDs exist.Verification
biome check apps/web/actions/videos/new-comment.ts— clean.tsc --noEmitonapps/web: the remaining diagnostics on this file (TS6305, plus TS2488/TS18046 on the Effect generator) are not introduced by this change — the untouchedget-transcript.tsemits the identical set, since it uses the same pattern and the workspacetsconfigproject references aren't built in a plaintscrun. Verified by diffing the two files' diagnostics.@cap/web-domain,@cap/web-backendand@cap/databasebuild clean via turbo.Diff: 1 file, +22/-2.