Skip to content

Commit 7108486

Browse files
committed
fix(ffmpeg): sanitize output extension, stat before read, bound speed
- Sanitize all temp-file extensions (input name/MIME and output format) to [a-z0-9] so a crafted format like ../../../t.jpg cannot escape the temp dir (path traversal) - finalize() now checks output size via fs.stat before fs.readFile, so an oversized output is rejected without loading the whole file into memory - Bound the speed multiplier to [0.1, 100] in the contract (and block) to prevent tiny values producing pathologically large outputs
1 parent 52323f6 commit 7108486

3 files changed

Lines changed: 34 additions & 17 deletions

File tree

apps/sim/app/api/tools/ffmpeg/process/route.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,26 @@ function getAudioCodec(format: string): string {
139139
}
140140

141141
/**
142-
* Derives a sensible file extension for an input temp file from its name or MIME type.
142+
* Reduces a user- or filename-derived extension to a safe `[a-z0-9]` token.
143+
* Strips path separators, dots, and other metacharacters so the value can be
144+
* interpolated into a temp file name without enabling path traversal.
143145
*/
144-
function getInputExtension(file: UserFile): string {
145-
const fromName = path
146-
.extname(file.name || '')
147-
.replace('.', '')
146+
function safeExtension(value: string | undefined): string {
147+
return (value ?? '')
148+
.trim()
148149
.toLowerCase()
150+
.replace(/[^a-z0-9]/g, '')
151+
.slice(0, 8)
152+
}
153+
154+
/**
155+
* Derives a safe file extension for an input temp file from its name or MIME type.
156+
*/
157+
function getInputExtension(file: UserFile): string {
158+
const fromName = safeExtension(path.extname(file.name || ''))
149159
if (fromName) return fromName
150-
const subtype = (file.type || '').split('/')[1]
151-
return subtype ? subtype.toLowerCase() : 'dat'
160+
const subtype = safeExtension((file.type || '').split('/')[1])
161+
return subtype || 'dat'
152162
}
153163

154164
function isVideoExtension(ext: string): boolean {
@@ -402,13 +412,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
402412
let mimeType = getMimeForFormat(inputExt)
403413

404414
if (operation === 'convert') {
405-
if (!body.format) {
415+
outExt = safeExtension(body.format)
416+
if (!outExt) {
406417
return NextResponse.json(
407-
{ error: 'format is required for the convert operation' },
418+
{ error: 'A valid output format is required for the convert operation (e.g. mp4, mp3)' },
408419
{ status: 400 }
409420
)
410421
}
411-
outExt = body.format.trim().toLowerCase()
412422
mimeType = getMimeForFormat(outExt)
413423
const outputPath = path.join(tempDir, `output.${outExt}`)
414424
await runFfmpeg((cmd) => {
@@ -421,7 +431,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
421431
}
422432

423433
if (operation === 'extract_audio') {
424-
outExt = (body.format?.trim() || 'mp3').toLowerCase()
434+
outExt = safeExtension(body.format) || 'mp3'
425435
mimeType = getMimeForFormat(outExt)
426436
const outputPath = path.join(tempDir, `output.${outExt}`)
427437
await runFfmpeg((cmd) =>
@@ -478,7 +488,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
478488
}
479489

480490
if (operation === 'thumbnail') {
481-
outExt = (body.format?.trim() || 'jpg').toLowerCase()
491+
outExt = safeExtension(body.format) || 'jpg'
482492
mimeType = getMimeForFormat(outExt)
483493
const time = body.time || '00:00:01'
484494
const outputPath = path.join(tempDir, `output.${outExt}`)
@@ -553,13 +563,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
553563
context: ExecutionContext | null,
554564
userId?: string
555565
): Promise<NextResponse> {
556-
const outputBuffer = await fs.readFile(outputPath)
557-
if (outputBuffer.length === 0) {
566+
// Check size via stat before reading so an oversized output is rejected
567+
// without first pulling the entire file into memory.
568+
const { size: outputSize } = await fs.stat(outputPath)
569+
if (outputSize === 0) {
558570
throw new Error('FFmpeg produced an empty output file')
559571
}
560-
if (outputBuffer.length > MAX_FFMPEG_OUTPUT_BYTES) {
572+
if (outputSize > MAX_FFMPEG_OUTPUT_BYTES) {
561573
throw new Error('Output file exceeds the maximum allowed size')
562574
}
575+
const outputBuffer = await fs.readFile(outputPath)
563576
const fileName = `ffmpeg-${operation}-${Date.now()}.${format}`
564577
const file = await storeOutputFile(outputBuffer, fileName, mimeType, context, userId)
565578
logger.info(`[${requestId}] FFmpeg ${operation} completed`, {

apps/sim/blocks/blocks/ffmpeg.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export const FfmpegBlock: BlockConfig<FfmpegFileResponse> = {
238238
videoBitrate: params.videoBitrate,
239239
time: params.time,
240240
volume: params.volume,
241-
speed: parseOptionalNumberInput(params.speed, 'Speed', { min: 0 }),
241+
speed: parseOptionalNumberInput(params.speed, 'Speed', { min: 0.1, max: 100 }),
242242
}
243243
},
244244
},

apps/sim/lib/api/contracts/tools/media/ffmpeg.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ export const ffmpegToolBodySchema = z
5656
/** Volume adjustment: a multiplier (`1.5`, `0.5`) or decibel value (`10dB`, `-6dB`). */
5757
volume: z.string().min(1).max(16).optional(),
5858
/** Playback speed multiplier for the `speed` operation (0.5 = half, 2 = double). */
59-
speed: z.coerce.number().positive().max(100).optional(),
59+
speed: z.coerce
60+
.number()
61+
.min(0.1, 'speed must be at least 0.1 (10x slower)')
62+
.max(100, 'speed must be at most 100 (100x faster)')
63+
.optional(),
6064
workspaceId: z.string().optional(),
6165
workflowId: z.string().optional(),
6266
executionId: z.string().optional(),

0 commit comments

Comments
 (0)