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