From bc74f15c278ad9347560f9d4484898e358f9f883 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:43:15 +0900 Subject: [PATCH 1/3] fix(web): gate getVideoAnalytics server action on video access policy 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. --- apps/web/actions/videos/get-analytics.ts | 33 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 83726d52eb2..7ffd82d6fa5 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -2,10 +2,11 @@ import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; -import { Tinybird } from "@cap/web-backend"; -import { Video } from "@cap/web-domain"; +import { provideOptionalAuth, Tinybird, VideosPolicy } from "@cap/web-backend"; +import { Policy, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; +import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -41,11 +42,27 @@ export async function getVideoAnalytics( ) { if (!videoId) throw new Error("Video ID is required"); - const [{ orgId } = { orgId: null }] = await db() - .select({ orgId: videos.orgId }) - .from(videos) - .where(eq(videos.id, Video.VideoId.make(videoId))) - .limit(1); + const id = Video.VideoId.make(videoId); + + // This is a server action, so it is directly callable by any client + // independently of `/api/analytics` — the gate on that route does not + // protect this entry point. Without this check the view count of a private + // or password-protected video is readable by anyone who knows its ID. + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db() + .select({ orgId: videos.orgId }) + .from(videos) + .where(eq(videos.id, id)) + .limit(1), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(id))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) throw new Error("Video not found"); + + const orgId = exit.value[0]?.orgId ?? null; return runPromise( Effect.gen(function* () { From 2d3051eb454cfdbad575410f18f89df4252017a2 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:56:15 +0900 Subject: [PATCH 2/3] fix(web): treat a missing video as not found in getVideoAnalytics Addresses review feedback: an empty result previously fell through and queried Tinybird without a tenant filter. --- apps/web/actions/videos/get-analytics.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 7ffd82d6fa5..e7a2045cd00 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -60,7 +60,12 @@ export async function getVideoAnalytics( ).pipe(Policy.withPublicPolicy(videosPolicy.canView(id))); }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); - if (Exit.isFailure(exit)) throw new Error("Video not found"); + // An empty result means the video does not exist. Falling through would + // query Tinybird with no tenant filter, which both wastes a query and + // widens the match beyond a single org. + if (Exit.isFailure(exit) || exit.value.length === 0) { + throw new Error("Video not found"); + } const orgId = exit.value[0]?.orgId ?? null; From 82d8e84165eb641b26d705e178f9360148bf56c9 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:02:37 +0900 Subject: [PATCH 3/3] docs: correct the rationale comment in getVideoAnalytics Addresses review feedback: pathname already scopes by videoId, so the earlier 'widens beyond a single org' framing overstated it. --- apps/web/actions/videos/get-analytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index e7a2045cd00..eaf5e68b3f6 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -60,9 +60,9 @@ export async function getVideoAnalytics( ).pipe(Policy.withPublicPolicy(videosPolicy.canView(id))); }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); - // An empty result means the video does not exist. Falling through would - // query Tinybird with no tenant filter, which both wastes a query and - // widens the match beyond a single org. + // If the video doesn't exist or isn't viewable, don't query Tinybird. + // Otherwise we'd fall through with a null orgId, dropping the tenant + // filter, and return analytics for an arbitrary `/s/${videoId}` pathname. if (Exit.isFailure(exit) || exit.value.length === 0) { throw new Error("Video not found"); }