@@ -725,44 +725,74 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
725725 )
726726 }
727727
728- const folderIdCache = new Map < string , string | null > ( )
729- const extractedFiles : UserFile [ ] = [ ]
730- let totalBytes = 0
728+ const entryTooLargeResponse = ( name : string ) =>
729+ NextResponse . json (
730+ {
731+ success : false ,
732+ error : `Archive entry "${ name } " is too large to extract. Maximum is ${
733+ MAX_DECOMPRESS_ENTRY_BYTES / ( 1024 * 1024 )
734+ } MB per file.`,
735+ } ,
736+ { status : 413 }
737+ )
738+ const totalTooLargeResponse = ( ) =>
739+ NextResponse . json (
740+ {
741+ success : false ,
742+ error : `Archive expands to more than the ${
743+ MAX_DECOMPRESS_TOTAL_BYTES / ( 1024 * 1024 )
744+ } MB extraction limit.`,
745+ } ,
746+ { status : 413 }
747+ )
731748
749+ // Reject standard zip bombs up front using the declared uncompressed sizes,
750+ // before materializing any entry into memory.
751+ let declaredTotal = 0
732752 for ( const entry of entries ) {
733753 const declaredSize = readEntryUncompressedSize ( entry )
734- if ( declaredSize !== undefined && declaredSize > MAX_DECOMPRESS_ENTRY_BYTES ) {
735- return NextResponse . json (
736- {
737- success : false ,
738- error : `Archive entry "${ entry . name } " is too large to extract. Maximum is ${
739- MAX_DECOMPRESS_ENTRY_BYTES / ( 1024 * 1024 )
740- } MB per file.`,
741- } ,
742- { status : 413 }
743- )
744- }
754+ if ( declaredSize === undefined ) continue
755+ if ( declaredSize > MAX_DECOMPRESS_ENTRY_BYTES ) return entryTooLargeResponse ( entry . name )
756+ declaredTotal += declaredSize
757+ if ( declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES ) return totalTooLargeResponse ( )
758+ }
745759
760+ // Read and validate every safe entry before writing anything, so a cap
761+ // breach never leaves partially-extracted files behind in the workspace.
762+ const pending : Array < { segments : string [ ] ; buffer : Buffer } > = [ ]
763+ let skippedCount = 0
764+ let totalBytes = 0
765+ for ( const entry of entries ) {
746766 const segments = sanitizeArchiveEntryPath ( entry . name )
747767 if ( ! segments ) {
768+ skippedCount += 1
748769 logger . warn ( 'Skipping unsafe archive entry' , { name : entry . name } )
749770 continue
750771 }
751772
752773 const buffer = await entry . async ( 'nodebuffer' )
774+ // Enforce the per-entry cap on the materialized size too, covering
775+ // entries that omit a declared uncompressed size.
776+ if ( buffer . length > MAX_DECOMPRESS_ENTRY_BYTES ) return entryTooLargeResponse ( entry . name )
753777 totalBytes += buffer . length
754- if ( totalBytes > MAX_DECOMPRESS_TOTAL_BYTES ) {
755- return NextResponse . json (
756- {
757- success : false ,
758- error : `Archive expands to more than the ${
759- MAX_DECOMPRESS_TOTAL_BYTES / ( 1024 * 1024 )
760- } MB extraction limit.`,
761- } ,
762- { status : 413 }
763- )
764- }
778+ if ( totalBytes > MAX_DECOMPRESS_TOTAL_BYTES ) return totalTooLargeResponse ( )
779+
780+ pending . push ( { segments, buffer } )
781+ }
782+
783+ if ( pending . length === 0 ) {
784+ return NextResponse . json (
785+ {
786+ success : false ,
787+ error : `No files could be extracted from "${ archive . name } ".` ,
788+ } ,
789+ { status : 422 }
790+ )
791+ }
765792
793+ const folderIdCache = new Map < string , string | null > ( )
794+ const extractedFiles : UserFile [ ] = [ ]
795+ for ( const { segments, buffer } of pending ) {
766796 const leafName = segments [ segments . length - 1 ]
767797 const folderSegments = segments . slice ( 0 , - 1 )
768798 const folderKey = folderSegments . join ( '/' )
@@ -792,6 +822,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
792822 fileId : archive . id ,
793823 name : archive . name ,
794824 extractedCount : extractedFiles . length ,
825+ skippedCount,
795826 } )
796827
797828 return NextResponse . json ( {
0 commit comments