From b47cac40ce46e3da7bbc69b0435e4a8086d68f80 Mon Sep 17 00:00:00 2001 From: hacklab Date: Thu, 15 Oct 2026 15:09:07 +0200 Subject: [PATCH] fix(video): logo overlays must belong to the workspace; drop debug stderr - produceVideo checks every logo overlay key against the workspace's media_assets, group_assets or branding logo before queueing; a well-formed content address alone no longer suffices. - Remove MEDIA_DEBUG_STDERR=1 from the production compose: it wrote raw ffmpeg stderr to an unrotated file, bypassing redaction. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/video/test-fixtures.ts | 5 +++++ apps/api/src/video/video.service.test.ts | 13 +++++++++++++ apps/api/src/video/video.service.ts | 23 +++++++++++++++++++++++ infra/occupymedia/docker-compose.app.yml | 1 - 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/api/src/video/test-fixtures.ts b/apps/api/src/video/test-fixtures.ts index 8e61d08..45640cb 100644 --- a/apps/api/src/video/test-fixtures.ts +++ b/apps/api/src/video/test-fixtures.ts @@ -16,6 +16,8 @@ export interface FakeVideoDatabase { transitions?: string[]; /** Raw INSERT statements seen, for asserting what intake wrote. */ statements?: Array<{ sql: string; values: readonly unknown[] }>; + /** Image storage keys this workspace holds (logo overlay ownership). */ + ownedImageKeys?: string[]; } function result(rows: Array>): QueryResult> { @@ -88,6 +90,9 @@ export function serviceWithDatabase( if (sql === "SELECT * FROM video_assets WHERE id = $1") { return result(database.asset === undefined ? [] : [database.asset]); } + if (sql.includes("branding->>'logoAssetKey'")) { + return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []); + } if (sql.includes("SET state = 'probing'")) { if (database.job?.state !== "queued") return result([]); database.job.state = "probing"; diff --git a/apps/api/src/video/video.service.test.ts b/apps/api/src/video/video.service.test.ts index e08c310..a40e5b9 100644 --- a/apps/api/src/video/video.service.test.ts +++ b/apps/api/src/video/video.service.test.ts @@ -148,6 +148,19 @@ describe("video byte ingest and processing-job state machine", () => { }); }); + it("accepts a logo overlay only when the image belongs to the workspace", async () => { + const owned = `sha256/${"a".repeat(64)}.png`; + const foreign = `sha256/${"b".repeat(64)}.png`; + const database: FakeVideoDatabase = { ownedImageKeys: [owned] }; + const { service } = serviceWithDatabase(database, async () => ({})); + await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4"); + const logo = (key: string): ProduceRequest => ({ overlays: [{ kind: "logo", logoStorageKey: key, position: { x: 90, y: 5 } }] }); + await expect(service.produceVideo(assetId, workspaceId, logo(foreign), "u")).rejects.toMatchObject({ status: 400 }); + expect(database.statements?.some((s) => s.sql.includes("INSERT INTO video_processing_jobs") && String(s.values[3]).includes(foreign))).toBe(false); + const produced = await service.produceVideo(assetId, workspaceId, logo(owned), "u"); + expect(produced.job.state).toBe("queued"); + }); + it("refuses to produce from an unknown asset", async () => { const { service } = serviceWithDatabase({}, async () => ({})); await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 }); diff --git a/apps/api/src/video/video.service.ts b/apps/api/src/video/video.service.ts index 72cfd68..ebec38b 100644 --- a/apps/api/src/video/video.service.ts +++ b/apps/api/src/video/video.service.ts @@ -381,6 +381,7 @@ export class VideoService { const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => { const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]); if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`); + await assertLogoKeysOwned(client, workspaceId, request); const jobResult = await client.query( `INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json) VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`, @@ -595,3 +596,25 @@ function decodeCursor(cursor: string): VideoCursor { function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } + +/** + * Storage keys are content-addressed in one shared bucket, so a well-formed key + * alone proves nothing about who owns the object. A logo overlay may only use + * an image this workspace holds: an attachment, a group asset or its branding. + */ +async function assertLogoKeysOwned( + client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> }, + workspaceId: string, + request: ProduceRequest, +): Promise { + const keys = [...new Set((request.overlays ?? []).flatMap((overlay) => (overlay.logoStorageKey === undefined ? [] : [overlay.logoStorageKey])))]; + for (const key of keys) { + const owned = await client.query( + `SELECT 1 WHERE EXISTS (SELECT 1 FROM media_assets WHERE storage_key = $1 AND workspace_id = $2) + OR EXISTS (SELECT 1 FROM group_assets WHERE asset_id = $1 AND workspace_id = $2) + OR EXISTS (SELECT 1 FROM workspaces WHERE id = $2 AND branding->>'logoAssetKey' = $1)`, + [key, workspaceId], + ); + if (owned.rowCount !== 1) throw new BadRequestException("logoStorageKey is not an image of this collective"); + } +} diff --git a/infra/occupymedia/docker-compose.app.yml b/infra/occupymedia/docker-compose.app.yml index 9113fc4..83014ff 100644 --- a/infra/occupymedia/docker-compose.app.yml +++ b/infra/occupymedia/docker-compose.app.yml @@ -176,7 +176,6 @@ services: <<: *appenv MEDIA_WORKER_LANE: local MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce - MEDIA_DEBUG_STDERR: "1" volumes: [mediaworkerstate:/var/lib/media-worker] depends_on: postgres: { condition: service_healthy }