Skip to content

Commit aae3d46

Browse files
committed
fix(file): make decompress extraction atomic and bound per-entry size
- Read and validate every entry before writing any file, so hitting a size cap no longer leaves partially-extracted files in the workspace (cursor) - Enforce the per-entry cap on the materialized buffer in addition to the declared size, covering entries that omit an uncompressed size (cursor) - Pre-check declared sizes up front to reject standard zip bombs before materializing, and return 422 when no files could be extracted (cursor)
1 parent 6a7d1c2 commit aae3d46

1 file changed

Lines changed: 56 additions & 25 deletions

File tree

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

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

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)