fix(api): enforce ownership check on AI generation endpoint - #1749
fix(api): enforce ownership check on AI generation endpoint#1749MinitJain wants to merge 5 commits into
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1b13b9a to
601731b
Compare
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
| import { and, eq } from "drizzle-orm"; | ||
| import type { NextRequest } from "next/server"; | ||
| import { startAiGeneration } from "@/lib/generate-ai"; | ||
| import * as EffectRuntime from "@/lib/server"; |
There was a problem hiding this comment.
EffectRuntime looks unused now that the Effect/Policy code path is gone — worth dropping to avoid unused-import lint failures.
| } | ||
|
|
||
| const result = exit.value; | ||
| const result = await db() |
There was a problem hiding this comment.
Minor: since videos.id is unique, you can limit(1) and avoid the result[0] dance.
| const result = await db() | |
| const [video] = await db() | |
| .select() | |
| .from(videos) | |
| .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))) | |
| .limit(1); | |
| if (!video) { | |
| return Response.json( | |
| { error: true, message: "Video not found" }, | |
| { status: 404 }, | |
| ); | |
| } |
… type - Add @fortawesome/fontawesome-svg-core to package.json (was missing, causing "Cannot find module" typecheck error in Footer.tsx) - Fix createVideo return type in caption-tracks.test.ts to include FakeVideo intersection so video.dispatch() typechecks correctly
| "@effect/rpc": "^0.71.0", | ||
| "@effect/sql-mysql2": "^0.47.0", | ||
| "@effect/workflow": "^0.11.3", | ||
| "@fortawesome/fontawesome-svg-core": "^6.7.2", |
There was a problem hiding this comment.
Did you mean to include the workspace lockfile update (e.g. pnpm-lock.yaml) with this dependency bump? If CI runs with a frozen lockfile, it’ll fail if the lock isn’t updated.
Replace `import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"`
with a local type alias `type IconDefinition = typeof faDiscord`.
fontawesome-svg-core is a transitive dep not listed in package.json;
importing it directly breaks CI's frozen-lockfile install.
| faXTwitter, | ||
| } from "@fortawesome/free-brands-svg-icons"; | ||
|
|
||
| type IconDefinition = typeof faDiscord; |
There was a problem hiding this comment.
Minor: having type IconDefinition = ... between imports can trip import/first / import-order rules. If you hit lint noise, consider moving this alias below the full import block.
| } | ||
|
|
||
| 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 not found" }, | ||
| { status: 404 }, | ||
| ); | ||
| } | ||
|
|
||
| const result = exit.value; | ||
| if (result.length === 0 || !result[0]) { | ||
| const [video] = await db() | ||
| .select() | ||
| .from(videos) |
There was a problem hiding this comment.
Minor perf: this handler only needs metadata, transcriptionStatus, and ownerId — selecting the whole videos row may pull large columns unnecessarily.
| } | |
| 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 not found" }, | |
| { status: 404 }, | |
| ); | |
| } | |
| const result = exit.value; | |
| if (result.length === 0 || !result[0]) { | |
| const [video] = await db() | |
| .select() | |
| .from(videos) | |
| const [video] = await db() | |
| .select({ | |
| ownerId: videos.ownerId, | |
| metadata: videos.metadata, | |
| transcriptionStatus: videos.transcriptionStatus, | |
| }) | |
| .from(videos) | |
| .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))) | |
| .limit(1); |
…y needed video columns - Move `type IconDefinition` below all import statements to satisfy Biome organizeImports ordering rule - Select only ownerId/metadata/transcriptionStatus from videos table instead of SELECT * (tembo perf suggestion)
|
Closing: already covered on main. The /api/video/ai route now gates reads through Policy.withPublicPolicy(videosPolicy.canView(videoId)) (from #1926) and scopes AI generation to the video owner. The ownership gap this PR targeted is no longer present. Thanks. |
Summary
GET /api/video/aiauthenticated the caller but never verified they own the target video?videoId=<any_video_id>and trigger AI generation billed to the video owner's accounteq(videos.ownerId, user.id)to the DB query — non-owners get a 404 (same as "not found"), so the existence of other users' videos is not leakedSecurity Impact
This is an IDOR (Insecure Direct Object Reference). An attacker with a free account could exhaust another user's paid AI generation quota by repeatedly triggering generation on their videos.
Test plan