fix(web): require canView on transcript and AI metadata endpoints#1926
Conversation
|
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. |
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
| const exit = await Effect.gen(function* () { | ||
| const videosPolicy = yield* VideosPolicy; | ||
|
|
||
| return yield* Effect.promise(() => | ||
| db().select({ video: videos }).from(videos).where(eq(videos.id, videoId)), | ||
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); |
There was a problem hiding this comment.
Double DB round-trip for the same video row
VideosPolicy.canView calls repo.getById(videoId) internally (see buildCanView in packages/web-backend/src/Videos/VideosPolicy.ts), then the outer Effect.promise(() => db().select({ video: videos })…) fetches the exact same row a second time. The same pattern is present in get-available-translations.ts, translate-transcript.ts, and api/video/ai/route.ts. Since withPublicPolicy runs the policy before the wrapped effect, every request now incurs two sequential reads for the same video. If this becomes a measurable latency concern, the results from canView's internal fetch could be threaded out and reused instead of re-querying.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/videos/get-transcript.ts
Line: 26-32
Comment:
**Double DB round-trip for the same video row**
`VideosPolicy.canView` calls `repo.getById(videoId)` internally (see `buildCanView` in `packages/web-backend/src/Videos/VideosPolicy.ts`), then the outer `Effect.promise(() => db().select({ video: videos })…)` fetches the exact same row a second time. The same pattern is present in `get-available-translations.ts`, `translate-transcript.ts`, and `api/video/ai/route.ts`. Since `withPublicPolicy` runs the policy *before* the wrapped effect, every request now incurs two sequential reads for the same video. If this becomes a measurable latency concern, the results from `canView`'s internal fetch could be threaded out and reused instead of re-querying.
How can I resolve this? If you propose a fix, please make it concise.| export const RATE_LIMIT_IDS = { | ||
| /** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */ | ||
| AUTH_OTP_VERIFY: "rl_auth_otp_verify", | ||
| /** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */ | ||
| AUTH_OTP_SEND: "rl_auth_otp_send", | ||
| /** Unauthed Loom download/convert (ffmpeg + memory DoS). Suggested: 10 / 1m per IP. */ | ||
| LOOM_DOWNLOAD: "rl_loom_download", | ||
| /** Unauthed transcript translation (Groq cost). Suggested: 10 / 1m per IP. */ | ||
| TRANSLATE_TRANSCRIPT: "rl_translate_transcript", | ||
| /** Anonymous support-chat messages (Groq + Supermemory cost). Suggested: 20 / 1m per IP. */ | ||
| MESSENGER_MESSAGE: "rl_messenger_message", | ||
| /** Unauthed analytics view tracking (Tinybird ingest + notifications). Suggested: 60 / 1m per IP. */ | ||
| ANALYTICS_TRACK: "rl_analytics_track", | ||
| /** Unauthed guest checkout (Stripe object/cost abuse). Suggested: 10 / 10m per IP. */ | ||
| GUEST_CHECKOUT: "rl_guest_checkout", | ||
| /** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */ | ||
| DESKTOP_LOGS: "rl_desktop_logs", | ||
| } as const; |
There was a problem hiding this comment.
RATE_LIMIT_IDS entries without corresponding call-sites in this PR
Several IDs — AUTH_OTP_VERIFY, AUTH_OTP_SEND, LOOM_DOWNLOAD, MESSENGER_MESSAGE, ANALYTICS_TRACK, GUEST_CHECKOUT, DESKTOP_LOGS — are defined here but isRateLimited is only wired up for TRANSLATE_TRANSCRIPT in this PR. The doc-comment already explains that an ID with no matching Vercel Firewall dashboard rule fails open, so the constants themselves are harmless. That said, it is worth verifying the IDs are either already used elsewhere or are tracked for follow-up — a constant that silently provides no protection could be misleading.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/rate-limit.ts
Line: 59-76
Comment:
**`RATE_LIMIT_IDS` entries without corresponding call-sites in this PR**
Several IDs — `AUTH_OTP_VERIFY`, `AUTH_OTP_SEND`, `LOOM_DOWNLOAD`, `MESSENGER_MESSAGE`, `ANALYTICS_TRACK`, `GUEST_CHECKOUT`, `DESKTOP_LOGS` — are defined here but `isRateLimited` is only wired up for `TRANSLATE_TRANSCRIPT` in this PR. The doc-comment already explains that an ID with no matching Vercel Firewall dashboard rule fails open, so the constants themselves are harmless. That said, it is worth verifying the IDs are either already used elsewhere or are tracked for follow-up — a constant that silently provides no protection could be misleading.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
@greptileai please review the PR |
| ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); | ||
| }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); | ||
|
|
||
| if (Exit.isFailure(exit)) { |
There was a problem hiding this comment.
Minor: this treats any failure (including unexpected DB/runtime errors) as "Video not found". If that’s intentional for the client response, it might still be worth logging the failure so real issues don’t get silently masked in production.
| headers: headersList, | ||
| }); | ||
|
|
||
| const { rateLimited } = await checkRateLimit(ruleId, { |
There was a problem hiding this comment.
Since checkRateLimit can fail open with error: "not-found", it feels easy to think you’re protected when the dashboard rule is missing. Consider warning when that happens (ideally once per ruleId) so misconfig doesn’t silently disable the limit.
| const { rateLimited } = await checkRateLimit(ruleId, { | |
| const { rateLimited, error } = await checkRateLimit(ruleId, { | |
| request, | |
| ...(opts?.key ? { rateLimitKey: opts.key } : {}), | |
| }); | |
| if (error === "not-found") { | |
| console.warn(`Rate limit rule not found for "${ruleId}" (Vercel Firewall).`); | |
| } | |
| return rateLimited; |
Gates the transcript, translate-transcript, available-translations and AI-metadata endpoints behind the same canView policy used elsewhere, so private and password-protected videos' content is no longer readable by unauthorized callers. Also rate-limits the translation endpoint.
Greptile Summary
This PR closes an authorization gap by wrapping the transcript, translate-transcript, available-translations, and AI-metadata endpoints in the same
canViewpolicy already used elsewhere, and adds Vercel Firewall rate limiting to the translation endpoint.Policy.withPublicPolicy(videosPolicy.canView(videoId))viaprovideOptionalAuth; policy failures and not-found cases both return the same opaque message to avoid leaking video existence for private/password-protected content.apps/web/lib/rate-limit.tsintroduces a sharedisRateLimitedhelper backed by@vercel/firewall; it is best-effort (fails open on error) and no-ops outside production, matching the pattern already established inactions/collections/password.ts.RATE_LIMIT_IDSconstants beyondTRANSLATE_TRANSCRIPTare placeholders for future rules; only the translation endpoint is wired in this PR.Confidence Score: 5/5
Safe to merge — the change only adds access-control gates and a rate-limit check; existing authorized callers are unaffected.
The authorization fix is applied consistently across all four endpoints using the same well-established pattern. The rate-limit helper is best-effort (fails open), so there is no availability risk. No logic regressions were found in the happy paths.
No files require special attention.
Important Files Changed
Comments Outside Diff (1)
apps/web/actions/videos/get-transcript.ts, line 17 (link)provideOptionalAuthgetCurrentUser()is called here and the resultinguseris stored, but the only thing it feeds downstream is the error-log fielduserId: user?.idon line 82. The actual access-control decision is now made byprovideOptionalAuthinside the Effect pipeline, which performs its own independent session read. Every call togetTranscripttherefore hits the session store twice. Theuservariable could be removed; the logging could rely on theownerIdalready present on thevideoobject, orprovideOptionalAuthcould be made to surface the resolved user if needed.Prompt To Fix With AI
Reviews (2): Last reviewed commit: "fix(web): require canView on transcript ..." | Re-trigger Greptile