fix(file-decompress): enforce decompression caps on inflated stream, not declared zip size#5154
Conversation
…not declared zip size
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Instead of Successful entries still accumulate into Reviewed by Cursor Bugbot for commit 789e99c. Configure here. |
Greptile SummaryThis PR fixes a zip bomb / DoS vulnerability in the
Confidence Score: 5/5The change is a tightly scoped security fix confined to one helper function and its call site; the streaming teardown logic is sound, the idempotent settle() guard prevents double-resolve on any exit path, and the error handler now correctly destroys the stream before rejecting. The new inflateEntryWithinCaps function correctly enforces caps on real inflated bytes. settle() is idempotent and calls stream.destroy() before resolving, preventing resource leaks. The error handler guards with if (settled) return and also calls stream.destroy() — matching the teardown that every other exit path performs. The route handler's totalBytes bookkeeping remains consistent: remainingTotalBudget is always non-negative when passed in, and totalBytes can never exceed MAX_DECOMPRESS_TOTAL_BYTES after a successful inflate. The advisory fast-reject on declared sizes is preserved and correctly labelled as non-authoritative. No files require special attention; the single changed file is apps/sim/app/api/tools/file/manage/route.ts and the security-critical streaming logic has been reviewed and found correct. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant R as Route Handler
participant I as inflateEntryWithinCaps
participant S as JSZip nodeStream
participant Z as DEFLATE decompressor
R->>I: call(entry, remainingTotalBudget)
I->>S: entry.nodeStream()
I->>S: listen on data/end/error
S->>Z: pipe decompressed data
loop Each chunk
Z-->>S: deflated chunk
S-->>I: emit data chunk
alt "size > MAX_DECOMPRESS_ENTRY_BYTES"
I->>S: stream.destroy()
I-->>R: ok false, reason entry
else "size > remainingTotalBudget"
I->>S: stream.destroy()
I-->>R: ok false, reason total
else within caps
I->>I: chunks.push(chunk)
end
end
S-->>I: emit end
I-->>R: ok true, buffer Buffer.concat(chunks)
Note over R: totalBytes += result.buffer.length
Note over R: pending.push segments and buffer
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant R as Route Handler
participant I as inflateEntryWithinCaps
participant S as JSZip nodeStream
participant Z as DEFLATE decompressor
R->>I: call(entry, remainingTotalBudget)
I->>S: entry.nodeStream()
I->>S: listen on data/end/error
S->>Z: pipe decompressed data
loop Each chunk
Z-->>S: deflated chunk
S-->>I: emit data chunk
alt "size > MAX_DECOMPRESS_ENTRY_BYTES"
I->>S: stream.destroy()
I-->>R: ok false, reason entry
else "size > remainingTotalBudget"
I->>S: stream.destroy()
I-->>R: ok false, reason total
else within caps
I->>I: chunks.push(chunk)
end
end
S-->>I: emit end
I-->>R: ok true, buffer Buffer.concat(chunks)
Note over R: totalBytes += result.buffer.length
Note over R: pending.push segments and buffer
Reviews (2): Last reviewed commit: "fix(file-decompress): destroy inflate st..." | Re-trigger Greptile |
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 789e99c. Configure here.
Summary
decompressoperation (POST /api/tools/file/manage,operation: "decompress") enforced its per-entry and total size caps only afterJSZiphad already fully inflated each entry into a NodeBuffer. The only guard running before inflation trusted the archive's declared (attacker-controlled) uncompressed size, so a crafted ZIP with a forged-small or absent declared size bypassed the pre-check and OOM-crashed the shared Node worker (DoS affecting co-tenants on hosted sim.ai).entry.async('nodebuffer')with a streaming counting sink (inflateEntryWithinCaps) that inflates each entry throughentry.nodeStream()and tears the stream down the moment cumulative output would breach the per-entry cap or the remaining total budget. Enforcement now happens on the real inflated bytes as they arrive — peak memory is bounded by the cap plus one DEFLATE chunk.Type of Change
Testing
Tested manually: verified that an entry with a forged-small declared
uncompressedSize(100 B) over a 20 MB payload is aborted by the streaming sink with an "entry too large" result, that normal entries inflate byte-identically to the previousasync('nodebuffer')path (fully backwards compatible), and that empty entries still yield a 0-length buffer. Typecheck, biome, andcheck:api-validationall pass.Checklist