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.
This commit is contained in:
hacklab 2026-10-15 15:09:07 +02:00
parent dd5a6c8b35
commit 1b58768738
4 changed files with 41 additions and 1 deletions

View file

@ -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<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
@ -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";

View file

@ -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 });

View file

@ -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<void> {
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");
}
}

View file

@ -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 }