Skip to content

Commit 6a7d1c2

Browse files
committed
fix(file): harden compress naming per review
- Flatten zip entry names to a safe basename so untrusted fileInput names with .. or / cannot produce zip-slip entry paths (cursor) - Treat archiveName as a flat name landing at the workspace root instead of passing it through splitWorkspaceFilePath, which silently created folders for names with separators (greptile) - Add the upfront empty-input guard before any DB calls, matching the read and content operations (greptile)
1 parent 3a9ec67 commit 6a7d1c2

1 file changed

Lines changed: 25 additions & 10 deletions

File tree

  • apps/sim/app/api/tools/file/manage

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@ const stripExtension = (name: string): string => {
149149
return dot > 0 ? name.slice(0, dot) : name
150150
}
151151

152+
/**
153+
* Reduce an arbitrary name to a safe, flat file name: takes the final path
154+
* segment, drops directory and traversal components, and falls back when the
155+
* result would be empty or a dot segment. Used for zip entry names and the
156+
* compress archive name so untrusted input cannot introduce nested or
157+
* zip-slip-style paths.
158+
*/
159+
const toFlatFileName = (name: string, fallback: string): string => {
160+
const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim()
161+
if (!leaf || leaf === '.' || leaf === '..') return fallback
162+
return leaf
163+
}
164+
152165
/**
153166
* Return a zip entry name unique within `usedNames`, appending a numeric suffix
154167
* before the extension on collision (e.g., "data.csv" -> "data (1).csv").
@@ -554,6 +567,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
554567
: extractFileIdsFromInput(fileInput)
555568
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
556569

570+
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
571+
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
572+
}
573+
557574
const workspaceFiles = await Promise.all(
558575
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
559576
)
@@ -572,10 +589,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
572589
)
573590
.concat(selectedInputFiles)
574591

575-
if (userFiles.length === 0) {
576-
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
577-
}
578-
579592
const zip = new JSZip()
580593
const usedNames = new Set<string>()
581594
let totalBytes = 0
@@ -598,7 +611,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
598611
{ status: 413 }
599612
)
600613
}
601-
zip.file(uniqueZipEntryName(userFile.name, usedNames), buffer)
614+
zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer)
602615
}
603616

604617
const zipBuffer = await zip.generateAsync({
@@ -608,14 +621,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
608621
})
609622

610623
const requestedName = typeof archiveName === 'string' ? archiveName.trim() : ''
611-
const targetName = ensureZipExtension(
612-
requestedName || (userFiles.length === 1 ? stripExtension(userFiles[0].name) : 'archive')
613-
)
614-
const { folderSegments, leafName } = splitWorkspaceFilePath(targetName)
624+
const baseName = requestedName
625+
? toFlatFileName(requestedName, 'archive')
626+
: userFiles.length === 1
627+
? stripExtension(toFlatFileName(userFiles[0].name, 'archive'))
628+
: 'archive'
629+
const leafName = ensureZipExtension(baseName)
615630
const folderId = await ensureWorkspaceFileFolderPath({
616631
workspaceId,
617632
userId,
618-
pathSegments: folderSegments,
633+
pathSegments: [],
619634
})
620635
const result = await uploadWorkspaceFile(
621636
workspaceId,

0 commit comments

Comments
 (0)