Skip to content

fix(file-decompress): enforce decompression caps on inflated stream, not declared zip size#5154

Merged
waleedlatif1 merged 2 commits into
stagingfrom
fix/decompress-bomb
Jun 20, 2026
Merged

fix(file-decompress): enforce decompression caps on inflated stream, not declared zip size#5154
waleedlatif1 merged 2 commits into
stagingfrom
fix/decompress-bomb

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • The decompress operation (POST /api/tools/file/manage, operation: "decompress") enforced its per-entry and total size caps only after JSZip had already fully inflated each entry into a Node Buffer. 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).
  • Replaced entry.async('nodebuffer') with a streaming counting sink (inflateEntryWithinCaps) that inflates each entry through entry.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.
  • Kept the declared-size check as an advisory fast-reject for honestly-declared archives only; it is no longer the authoritative guard.

Type of Change

  • Bug fix (security)

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 previous async('nodebuffer') path (fully backwards compatible), and that empty entries still yield a 0-length buffer. Typecheck, biome, and check:api-validation all pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 20, 2026 8:59pm

Request Review

@cursor

cursor Bot commented Jun 20, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Security fix on a shared API decompression path; incorrect streaming/cap logic could break legitimate extracts or leave residual DoS risk on hosted workers.

Overview
Hardens zip decompression in POST /api/tools/file/manage (operation: "decompress") against zip-bomb / forged-metadata attacks that could fully inflate huge entries before size limits applied.

Instead of entry.async('nodebuffer'), each safe archive entry is inflated via inflateEntryWithinCaps, which reads entry.nodeStream() and destroys the stream as soon as per-entry (MAX_DECOMPRESS_ENTRY_BYTES) or remaining total (MAX_DECOMPRESS_TOTAL_BYTES) budgets would be exceeded. Limits are enforced on actual inflated bytes, not ZIP header uncompressedSize (documented as attacker-controlled and only used for an optional fast-reject).

Successful entries still accumulate into pending before any workspace writes, preserving the prior “no partial extraction on cap breach” behavior for valid archives.

Reviewed by Cursor Bugbot for commit 789e99c. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a zip bomb / DoS vulnerability in the decompress route where ZIP entries with forged (or absent) declared uncompressed sizes could bypass the pre-inflation cap check and fully materialise into memory, crashing the shared Node.js worker. The fix introduces inflateEntryWithinCaps, a streaming counting sink that enforces per-entry and total size caps on the actual inflated bytes as they arrive, keeping the old declared-size check only as a cheap fast-reject for well-formed archives.

  • inflateEntryWithinCaps: streams each entry via entry.nodeStream(), tears down the stream the instant cumulative output exceeds either MAX_DECOMPRESS_ENTRY_BYTES or the remaining total budget, and returns a discriminated InflateResult — peak memory is bounded to the cap plus one DEFLATE chunk.
  • Route handler loop: switches from entry.async('nodebuffer') (fully materialises in memory before checking) to await inflateEntryWithinCaps(entry, MAX_DECOMPRESS_TOTAL_BYTES - totalBytes), checking the result discriminant before accumulating totalBytes.
  • Error handler: per the prior review, correctly calls stream.destroy() before rejecting so the JSZip readable isn't left dangling on a mid-inflation DEFLATE error.

Confidence Score: 5/5

The 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

Filename Overview
apps/sim/app/api/tools/file/manage/route.ts Replaces post-inflation zip bomb check with streaming sink inflateEntryWithinCaps that enforces per-entry and total caps on actual inflated bytes; the previous reliance on attacker-controlled declared sizes is now demoted to advisory fast-reject only

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
Loading
%%{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
Loading

Reviews (2): Last reviewed commit: "fix(file-decompress): destroy inflate st..." | Re-trigger Greptile

Comment thread apps/sim/app/api/tools/file/manage/route.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@waleedlatif1 waleedlatif1 merged commit 83dc806 into staging Jun 20, 2026
16 checks passed
@waleedlatif1 waleedlatif1 deleted the fix/decompress-bomb branch June 20, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant