Skip to content

Enforce zip size limits on actual decompressed bytes#637

Closed
ErisDS wants to merge 1 commit into
mainfrom
fix/zip-streaming-bomb-defence
Closed

Enforce zip size limits on actual decompressed bytes#637
ErisDS wants to merge 1 commit into
mainfrom
fix/zip-streaming-bomb-defence

Conversation

@ErisDS
Copy link
Copy Markdown
Member

@ErisDS ErisDS commented May 14, 2026

Summary

Follow-up to #632. The size limits added in that PR are enforced at metadata pre-flight (entry.uncompressedSize), which catches honestly-declared oversize archives but not zip bombs that lie about their declared size.

This PR replaces extract-zip with yauzl directly and enforces the limits on actual decompressed bytes during streaming, closing that gap and removing one dependency.

Why the dependency swap was necessary

extract-zip is a thin wrapper around yauzl. yauzl already includes an AssertByteCountStream that should error mid-stream when actual bytes exceed declared. But yauzl overrides that stream's destroy with a closure that silently swallows the error — so when a bomb is detected, no error propagates, and extract-zip's pipeline() waits forever. Result on main today: a lying-header zip hangs extract() indefinitely.

The fix is to open yauzl with validateEntrySizes: false to skip yauzl's broken counter, and run our own StreamingCounter Transform whose errors propagate cleanly through pipeline(). extract-zip is no longer compatible with that approach, and it has been unmaintained since 2020, so we drop it.

What's preserved

All security-critical logic from extract-zip is ported verbatim:

  • Path-traversal guard (realpath + path.relative + .. check)
  • __MACOSX/ resource-fork skip
  • Symlink / directory / Windows-DOS-mode-16 detection
  • defaultDirMode / defaultFileMode handling
  • onEntry hook contract

All existing error codes and shapes are preserved (ENTRY_TOO_LARGE, TOTAL_TOO_LARGE, INVALID_ENTRY_SIZE, INVALID_ZIP_LIMIT).

New regression tests

  • rejects a zip whose central directory lies about uncompressed size (zip-bomb defence) — builds a real zip, forges its central directory uncompressedSize to 5 bytes while actual payload is 1 MB, asserts clean rejection (not a hang)
  • rejects a zip whose cumulative actual bytes exceed the total limit — two-entry version of the same attack against the total
  • extracts a lying zip when no limits are configured — same lying zip with no limits — used to hang under extract-zip+yauzl, now resolves cleanly with the actual bytes on disk
  • rejects an entry whose path escapes the destination directory — locks in the path-traversal guard (via yauzl mock, since archiver normalizes ../ out)

Test plan

  • pnpm --filter @tryghost/zip test — 19/19 pass, coverage above thresholds, lint clean
  • Sanity-check: extract a real Ghost theme zip with no limits set
  • Sanity-check: extract a real Ghost theme zip with limits set above the theme's actual size
  • Verify the lying-header bomb test fails on main (it hangs)

🤖 Generated with Claude Code

Switches @tryghost/zip from `extract-zip` to `yauzl` directly so that
the new size limits are enforced against actual decompressed bytes,
not just the central-directory metadata. A zip whose header lies about
its uncompressed size (the classic zip-bomb shape) previously slipped
past the pre-flight check and then hung the underlying extract-zip
pipeline indefinitely — because yauzl's AssertByteCountStream overrides
`destroy` in a way that swallows mid-stream errors. The new
implementation opens yauzl with `validateEntrySizes: false` and runs
its own StreamingCounter Transform that propagates errors cleanly
through the pipeline.

Behaviour is otherwise preserved: same path-traversal, __MACOSX,
symlink, filename-length, and mode-handling logic ported verbatim
from extract-zip; same error codes and shapes; same onEntry hook.

extract-zip is removed as a dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

This PR replaces the extract-zip library with yauzl and implements streaming-based uncompressed-size limit enforcement to defend against zip-bomb attacks. The new limits option (per-entry and total byte thresholds) is enforced against actual decompressed bytes during streaming rather than relying on central-directory metadata alone, catching cases where headers lie about entry sizes. The implementation adds error constructors, stream-counting primitives, refactored entry processing logic, and comprehensive tests including zip-bomb defense scenarios with forged central-directory headers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • TryGhost/framework#632: Adds per-entry and total uncompressed-size limit plumbing to extract.js; main PR shifts enforcement to streaming actual decompressed bytes.
  • TryGhost/framework#636: Modifies symlink handling and filename-length checks in extract.js; main PR rewrite integrates these safety validations into the new yauzl-based flow.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main change: replacing metadata-based limit enforcement with actual decompressed byte enforcement during streaming.
Description check ✅ Passed The description is detailed and directly related to the changeset, explaining the motivation, implementation approach, preserved logic, and test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/zip-streaming-bomb-defence

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@ErisDS
Copy link
Copy Markdown
Member Author

ErisDS commented May 14, 2026

Reopening with a less scary branch name → new PR.

@ErisDS ErisDS closed this May 14, 2026
@ErisDS ErisDS deleted the fix/zip-streaming-bomb-defence branch May 14, 2026 11:39
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.90566% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.59%. Comparing base (6a18dff) to head (3b82d12).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
packages/zip/lib/extract.js 84.90% 8 Missing and 8 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #637      +/-   ##
==========================================
- Coverage   98.12%   97.59%   -0.54%     
==========================================
  Files          84       84              
  Lines        2770     2865      +95     
  Branches      510      527      +17     
==========================================
+ Hits         2718     2796      +78     
- Misses         11       20       +9     
- Partials       41       49       +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

2 participants