Skip to content

Commit 260abe8

Browse files
committed
fix(media): address ffmpeg hardening review findings
Follow-up to the input/runtime/output-path bounds, from a multi-agent review of that change. Regressions the first pass introduced: - webp and weba were in MIME_TO_EXT but not EXT_TO_MIME, so convert to either hard-failed where it previously worked. Both are now valid outputs. - Bounds were asserted for every operation, so a surplus out-of-range value an operation never reads (overlay_audio + volume) failed the whole call. Each operation now validates only what it consumes. - width/height of 0 bypassed the scale check via a truthy guard. - clampProbedDimension raised a probed 0 to 16 instead of falling back to the default, yielding a 16x16 concat. Bugs found in the new code: - fluent-ffmpeg's kill() is a no-op until the child spawns, and .save() spawns asynchronously. A kill landing in that window rejected the promise while the encode spawned orphaned and unkillable. Re-issue the kill on 'start'. - ffprobe ran with -v quiet, which left a timeout, a corrupt file, and a missing file byte-identical and uninformative. Use -v error and report the distinct cause, with the server's paths stripped from the diagnostic. - resolveFfprobePath narrowed fluent-ffmpeg's lookup; restore FFPROBE_PATH and PATH fallback with existence checks. Also: reject a trim whose end precedes its start rather than silently writing an empty file, restrict extract_audio to audio containers, name the actionable cause in the timeout message, and drop a redundant second probe budget. Tests: assert no temp dir is created on an already-aborted signal (the previous assertion passed with the guard reverted), cover the 0-dimension and end-before-start cases, and track MAX_FFMPEG_INPUTS instead of a literal. Reviewers also flagged that LLM-supplied numerics arrive as strings; verified against the router that Ajv rejects those upstream, so no coercion was added.
1 parent 4dc5a67 commit 260abe8

3 files changed

Lines changed: 145 additions & 31 deletions

File tree

apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', ()
5959
}))
6060

6161
import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg'
62+
import { MAX_FFMPEG_INPUTS } from '@/lib/media/ffmpeg'
6263

6364
const EXACT_EMPTY = { status: 'exact' as const, entries: [] }
6465
const TRACKED = {
@@ -315,14 +316,14 @@ describe('ffmpeg server tool secret provenance', () => {
315316
{
316317
operation: 'concat',
317318
inputs: {
318-
files: Array.from({ length: 11 }, () => ({ path: 'files/input.mp4' })),
319+
files: Array.from({ length: MAX_FFMPEG_INPUTS + 1 }, () => ({ path: 'files/input.mp4' })),
319320
},
320321
},
321322
context
322323
)
323324

324325
expect(result.success).toBe(false)
325-
expect(result.message).toContain('At most 10 input files')
326+
expect(result.message).toContain(`At most ${MAX_FFMPEG_INPUTS} input files`)
326327
expect(resolveWorkspaceFileReferenceMock).not.toHaveBeenCalled()
327328
expect(runFfmpegOperationMock).not.toHaveBeenCalled()
328329
})

apps/sim/lib/media/ffmpeg.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import fs from 'node:fs/promises'
5+
import { describe, expect, it, vi } from 'vitest'
56
import { MAX_FFMPEG_INPUTS, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg'
67

78
function mediaFile(mimeType = 'video/mp4'): MediaFile {
@@ -47,6 +48,15 @@ describe('runFfmpegOperation output format validation', () => {
4748
runFfmpegOperation('extract_audio', [mediaFile()], { format: '../../escape.mp3' })
4849
).rejects.toThrow('Unsupported output format')
4950
})
51+
52+
it.each(['webp', 'weba', 'mp4', 'gif'])(
53+
'still accepts %s, which the input MIME map already supported',
54+
async (format) => {
55+
await expect(runFfmpegOperation('convert', [mediaFile()], { format })).rejects.not.toThrow(
56+
'Unsupported output format'
57+
)
58+
}
59+
)
5060
})
5161

5262
describe('runFfmpegOperation scale bounds', () => {
@@ -55,21 +65,50 @@ describe('runFfmpegOperation scale bounds', () => {
5565
[1, 1],
5666
[4097, 1080],
5767
[1920, 0],
68+
[0, 1080],
5869
[1920.5, 1080],
5970
])('rejects scale_pad at %sx%s', async (width, height) => {
6071
await expect(runFfmpegOperation('scale_pad', [mediaFile()], { width, height })).rejects.toThrow(
61-
/must be an integer between 16 and 4096|requires width\+height/
72+
'must be an integer between 16 and 4096'
6273
)
6374
})
6475
})
6576

77+
describe('runFfmpegOperation per-operation validation', () => {
78+
it('ignores options the operation never consumes', async () => {
79+
// overlay_audio does not read `volume`; an out-of-range surplus value from
80+
// the model must not fail the whole call.
81+
await expect(
82+
runFfmpegOperation('overlay_audio', [mediaFile(), mediaFile('audio/mpeg')], { volume: 15 })
83+
).rejects.not.toThrow(/volume/)
84+
})
85+
86+
it('rejects a trim whose end precedes its start', async () => {
87+
await expect(runFfmpegOperation('trim', [mediaFile()], { start: 10, end: 5 })).rejects.toThrow(
88+
'end (5s) must be greater than or equal to start (10s)'
89+
)
90+
})
91+
92+
it('restricts extract_audio to audio containers', async () => {
93+
await expect(
94+
runFfmpegOperation('extract_audio', [mediaFile()], { format: 'png' })
95+
).rejects.toThrow('Unsupported output format')
96+
})
97+
})
98+
6699
describe('runFfmpegOperation abort handling', () => {
67100
it('refuses to start once the signal is already aborted', async () => {
68101
const controller = new AbortController()
69102
controller.abort()
103+
const mkdtemp = vi.spyOn(fs, 'mkdtemp')
70104

71105
await expect(
72106
runFfmpegOperation('convert', [mediaFile()], { format: 'mp3' }, { signal: controller.signal })
73107
).rejects.toThrow(/aborted/i)
108+
109+
// "Refuses to start" means exactly this: no temp dir, so no input was ever
110+
// written and no process was ever spawned.
111+
expect(mkdtemp).not.toHaveBeenCalled()
112+
mkdtemp.mockRestore()
74113
})
75114
})

apps/sim/lib/media/ffmpeg.ts

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFile, execSync } from 'node:child_process'
2+
import { existsSync } from 'node:fs'
23
import fs from 'node:fs/promises'
34
import os from 'node:os'
45
import path from 'node:path'
@@ -31,13 +32,23 @@ function ensureFfmpeg(): void {
3132
}
3233
}
3334

34-
/** ffprobe ships alongside ffmpeg; fall back to PATH resolution. */
35+
/**
36+
* Mirrors fluent-ffmpeg's resolution order (FFPROBE_PATH, then PATH, then
37+
* ffmpeg's own directory) so replacing its ffprobe call does not narrow where
38+
* the binary may live for self-hosters.
39+
*/
3540
function resolveFfprobePath(): string {
3641
ensureFfmpeg()
37-
if (!ffmpegPath) return 'ffprobe'
38-
const dir = path.dirname(ffmpegPath)
3942
const binary = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'
40-
return path.join(dir, binary)
43+
44+
const configured = process.env.FFPROBE_PATH?.trim()
45+
if (configured && existsSync(configured)) return configured
46+
47+
if (ffmpegPath) {
48+
const sibling = path.join(path.dirname(ffmpegPath), binary)
49+
if (existsSync(sibling)) return sibling
50+
}
51+
return binary
4152
}
4253

4354
/**
@@ -53,6 +64,10 @@ export const DEFAULT_FFMPEG_TIMEOUT_MS = 5 * 60 * 1000
5364
const PROBE_TIMEOUT_MS = 15 * 1000
5465
const PROBE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
5566

67+
/** Names the actionable cause: the budget covers all clips, so fewer/shorter inputs is the fix. */
68+
const TIME_BUDGET_EXCEEDED =
69+
'FFmpeg operation exceeded its time budget — try fewer, shorter, or lower-resolution inputs'
70+
5671
export type FfmpegOperation =
5772
| 'overlay_audio'
5873
| 'mux'
@@ -110,7 +125,10 @@ export interface FfmpegResult {
110125
export interface FfmpegRunOptions {
111126
/** Aborts and SIGKILLs every process spawned for the operation. */
112127
signal?: AbortSignal
113-
/** Wall-clock budget for the whole operation. Defaults to DEFAULT_FFMPEG_TIMEOUT_MS. */
128+
/**
129+
* Wall-clock budget for the whole operation. Defaults to, and is capped at,
130+
* DEFAULT_FFMPEG_TIMEOUT_MS — a caller may shorten the ceiling, never raise it.
131+
*/
114132
timeoutMs?: number
115133
}
116134

@@ -155,12 +173,17 @@ const EXT_TO_MIME: Record<string, string> = {
155173
flac: 'audio/flac',
156174
aac: 'audio/aac',
157175
opus: 'audio/opus',
176+
weba: 'audio/webm',
158177
png: 'image/png',
159178
jpg: 'image/jpeg',
160179
jpeg: 'image/jpeg',
161180
gif: 'image/gif',
181+
webp: 'image/webp',
162182
}
163183

184+
/** extract_audio can only name an audio container; the rest would silently produce nothing useful. */
185+
const AUDIO_EXTS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus', 'weba'])
186+
164187
/**
165188
* Temp-file names are built as `${prefix}.${ext}` and joined against the temp
166189
* dir, so an extension carrying `/` or `..` escapes that dir once `path.join`
@@ -186,11 +209,12 @@ function mimeFromExt(ext: string): string {
186209
}
187210

188211
/** Only formats with a known muxer and a safe file name may name an output. */
189-
function resolveOutputExt(format: string): string {
190-
const ext = format.trim().toLowerCase()
191-
if (!isSafeExt(ext) || !EXT_TO_MIME[ext]) {
212+
function resolveOutputExt(format: string, allowed?: Set<string>): string {
213+
const ext = String(format).trim().toLowerCase()
214+
const supported = allowed ?? new Set(Object.keys(EXT_TO_MIME))
215+
if (!isSafeExt(ext) || !EXT_TO_MIME[ext] || !supported.has(ext)) {
192216
throw new Error(
193-
`Unsupported output format "${format}". Supported: ${Object.keys(EXT_TO_MIME).join(', ')}`
217+
`Unsupported output format "${format}". Supported: ${[...supported].join(', ')}`
194218
)
195219
}
196220
return ext
@@ -207,8 +231,8 @@ function resolveScaleDimension(value: number, label: 'width' | 'height'): number
207231
}
208232

209233
function clampProbedDimension(value: number | undefined, fallback: number): number {
210-
if (!Number.isInteger(value)) return fallback
211-
return Math.min(Math.max(value as number, MIN_SCALE_DIMENSION), MAX_SCALE_DIMENSION)
234+
if (!Number.isInteger(value) || (value as number) < MIN_SCALE_DIMENSION) return fallback
235+
return Math.min(value as number, MAX_SCALE_DIMENSION)
212236
}
213237

214238
function resolveNonNegativeSeconds(value: number, label: string): number {
@@ -229,21 +253,34 @@ function resolveVolume(value: number, label: string): number {
229253
* Every caller-supplied bound is checked before a temp dir is created or a
230254
* binary is resolved, so a rejected request costs nothing and the error is the
231255
* validation failure rather than a missing-FFmpeg message.
256+
*
257+
* Only the options an operation actually consumes are validated. An LLM caller
258+
* routinely emits surplus parameters, and failing the whole call over a value
259+
* the operation ignores would be a regression, not a safeguard.
232260
*/
233261
function assertOptionsWithinBounds(operation: FfmpegOperation, options: FfmpegOptions): void {
234-
if (options.start !== undefined) resolveNonNegativeSeconds(options.start, 'start')
235-
if (options.end !== undefined) resolveNonNegativeSeconds(options.end, 'end')
236-
if (options.volume !== undefined) resolveVolume(options.volume, 'volume')
237-
if (options.musicVolume !== undefined) resolveVolume(options.musicVolume, 'musicVolume')
238-
262+
if (operation === 'trim' || operation === 'thumbnail') {
263+
if (options.start !== undefined) resolveNonNegativeSeconds(options.start, 'start')
264+
}
265+
if (operation === 'trim' && options.end !== undefined) {
266+
const end = resolveNonNegativeSeconds(options.end, 'end')
267+
const start = resolveNonNegativeSeconds(options.start ?? 0, 'start')
268+
if (end < start) {
269+
throw new Error(`end (${end}s) must be greater than or equal to start (${start}s)`)
270+
}
271+
}
272+
if (operation === 'mix_audio') {
273+
if (options.volume !== undefined) resolveVolume(options.volume, 'volume')
274+
if (options.musicVolume !== undefined) resolveVolume(options.musicVolume, 'musicVolume')
275+
}
239276
if (operation === 'convert') {
240277
if (!options.format) throw new Error('convert requires a target format')
241278
resolveOutputExt(options.format)
242279
}
243280
if (operation === 'extract_audio') {
244-
resolveOutputExt(options.format || 'mp3')
281+
resolveOutputExt(options.format || 'mp3', AUDIO_EXTS)
245282
}
246-
if (operation === 'scale_pad' && options.width && options.height) {
283+
if (operation === 'scale_pad' && options.width !== undefined && options.height !== undefined) {
247284
resolveScaleDimension(options.width, 'width')
248285
resolveScaleDimension(options.height, 'height')
249286
}
@@ -308,7 +345,7 @@ class OperationBudget {
308345
throw new Error('FFmpeg operation aborted')
309346
}
310347
if (this.remainingMs() <= 0) {
311-
throw new Error('FFmpeg operation exceeded its time budget')
348+
throw new Error(TIME_BUDGET_EXCEEDED)
312349
}
313350
}
314351
}
@@ -372,17 +409,31 @@ function runCommand(
372409
}
373410
settle(new Error(reason))
374411
}
412+
/**
413+
* fluent-ffmpeg's kill() is a silent no-op until the child exists, and
414+
* `.save()` spawns asynchronously (it may shell out for capability checks
415+
* first). A kill landing in that window would otherwise reject the promise
416+
* while the encode goes on to spawn orphaned and unkillable — so re-issue
417+
* it once the process is up. 'start' fires immediately after the spawn.
418+
*/
419+
function onStart(): void {
420+
if (settled) {
421+
try {
422+
command.kill('SIGKILL')
423+
} catch {
424+
// Nothing to signal; the promise has already settled.
425+
}
426+
}
427+
}
375428
function onAbort(): void {
376429
kill('FFmpeg operation aborted')
377430
}
378431

379-
const timer = setTimeout(
380-
() => kill('FFmpeg operation exceeded its time budget'),
381-
budget.remainingMs()
382-
)
432+
const timer = setTimeout(() => kill(TIME_BUDGET_EXCEEDED), budget.remainingMs())
383433
budget.signal?.addEventListener('abort', onAbort, { once: true })
384434

385435
command
436+
.on('start', onStart)
386437
.on('end', () => settle())
387438
.on('error', (err) => settle(new Error(`FFmpeg error: ${err.message}`)))
388439
.save(outputPath)
@@ -410,6 +461,25 @@ interface FfprobeOutput {
410461
}>
411462
}
412463

464+
/**
465+
* Distinguishes the three ways a probe fails. Node's `execFile` error message
466+
* is `Command failed: <full argv>` plus stderr, which both leaks the server's
467+
* binary and temp paths to the caller and — once stderr is quiet — renders a
468+
* timeout, a corrupt file, and a missing file byte-identical.
469+
*/
470+
function describeProbeFailure(err: Error & { killed?: boolean; code?: unknown }, stderr: string) {
471+
if (err.code === 'ABORT_ERR') return 'aborted'
472+
if (err.killed) return 'timed out'
473+
const detail = stderr.trim().split('\n').pop()
474+
if (!detail) return 'unreadable media'
475+
// ffprobe prefixes its diagnostic with the input path; keep the diagnostic,
476+
// drop the server's directory layout.
477+
return detail.replace(
478+
/(^|\s)(\/\S+)/g,
479+
(_match, lead: string, abs: string) => `${lead}${path.basename(abs)}`
480+
)
481+
}
482+
413483
/**
414484
* Spawned directly rather than through `fluent-ffmpeg.ffprobe`, which gives no
415485
* handle on the child and so cannot be killed: a crafted input that wedges
@@ -421,16 +491,16 @@ function probeFile(filePath: string, budget: OperationBudget): Promise<MediaProb
421491
return new Promise((resolve, reject) => {
422492
execFile(
423493
resolveFfprobePath(),
424-
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', '-i', filePath],
494+
['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', '-i', filePath],
425495
{
426496
timeout,
427497
killSignal: 'SIGKILL',
428498
maxBuffer: PROBE_MAX_OUTPUT_BYTES,
429499
signal: budget.signal,
430500
},
431-
(err, stdout) => {
501+
(err, stdout, stderr) => {
432502
if (err) {
433-
reject(new Error(`FFprobe error: ${err.message}`))
503+
reject(new Error(`FFprobe error: ${describeProbeFailure(err, stderr)}`))
434504
return
435505
}
436506
let metadata: FfprobeOutput
@@ -483,7 +553,11 @@ export async function runFfmpegOperation(
483553
budget.assertLive()
484554

485555
if (operation === 'probe') {
486-
return { probe: await probeMedia(inputs[0], runOptions) }
556+
return {
557+
probe: await withTempDir(budget, async ({ dir }) =>
558+
probeFile(await writeInput(dir, inputs[0], 0), budget)
559+
),
560+
}
487561
}
488562

489563
return withTempDir(budget, async (ctx) => {

0 commit comments

Comments
 (0)