Enforce zip size limits on actual decompressed bytes#637
Conversation
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>
|
Caution Review failedPull request was closed or merged during review WalkthroughThis PR replaces the Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
|
Reopening with a less scary branch name → new PR. |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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-zipwithyauzldirectly and enforces the limits on actual decompressed bytes during streaming, closing that gap and removing one dependency.Why the dependency swap was necessary
extract-zipis a thin wrapper aroundyauzl. yauzl already includes anAssertByteCountStreamthat should error mid-stream when actual bytes exceed declared. But yauzl overrides that stream'sdestroywith a closure that silently swallows the error — so when a bomb is detected, no error propagates, andextract-zip'spipeline()waits forever. Result on main today: a lying-header zip hangsextract()indefinitely.The fix is to open yauzl with
validateEntrySizes: falseto skip yauzl's broken counter, and run our ownStreamingCounterTransform whose errors propagate cleanly throughpipeline().extract-zipis 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-zipis ported verbatim:realpath+path.relative+..check)__MACOSX/resource-fork skipdefaultDirMode/defaultFileModehandlingonEntryhook contractAll 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 directoryuncompressedSizeto 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 totalextracts a lying zip when no limits are configured— same lying zip with no limits — used to hang underextract-zip+yauzl, now resolves cleanly with the actual bytes on diskrejects 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🤖 Generated with Claude Code