Skip to content

zlib: harden ZIP archive reading and writing against malicious input - #65016

Open
pipobscure wants to merge 6 commits into
nodejs:mainfrom
pipobscure:zip-security-fixes
Open

zlib: harden ZIP archive reading and writing against malicious input#65016
pipobscure wants to merge 6 commits into
nodejs:mainfrom
pipobscure:zip-security-fixes

Conversation

@pipobscure

Copy link
Copy Markdown
Contributor

Summary

This PR fixes four security issues in the node:zlib ZIP support (ZipEntry/ZipFile/ZipBuffer), found by an audit of the parser and the on-disk reader/writer. Two are high-severity (a parser-confusion divergence and a denial-of-service hang); two are medium-severity lifecycle/robustness bugs. Each issue is landed test-first: a commit adds a failing regression test, followed by the fix that turns it green.

The ZIP surface treats an archive (bytes in a Buffer, or a file on disk) and every header field as fully attacker-controlled, so these are all reachable from untrusted input.


1. Local vs. central header disagreement — parser confusion (high)

Issue. The reader treats the central directory as authoritative for a member's compression method, sizes, CRC, and name, but only validated the local file header's signature. It never checked that the two headers agree. Two consequences:

  • Different bytes than other tools. Because Info-ZIP unzip extracts using the local header's method/size, an archive whose local and central headers disagree yields different content in Node than in unzip for the same member name — a classic inspect-then-consume bypass (a scanner sees content Q; Node consumes content P). Verified against unzip/python -m zipfile.
  • Encryption-flag confusion. The "is this member encrypted?" decision was read from the local header while the member's identity came from the central one. An attacker could mark a member encrypted in the central directory (so a central-directory-based scanner such as Python's zipfile treats it as opaque and skips it) while leaving the local flag clear, so Node silently decoded the plaintext payload.

Solution. When a member is read, cross-check the local file header against the central entry — compression method, CRC-32, compressed size, and uncompressed size (with a spec-compliant exemption for data-descriptor entries, whose local CRC/sizes are legitimately zero), plus the encryption flag — and reject any disagreement with ERR_ZIP_INVALID_ARCHIVE. This follows the module's existing "reject rather than silently choose one interpretation" stance and removes Node from both sides of any local/central divergence.

The existing hardening/coverage tests that forged a decode-time size/CRC/Zip64 lie in the central header alone now trip this earlier check; they were updated to patch both headers consistently so they still exercise the decode-time guards they target.

2. zipFiles() hangs on a FIFO/special source — DoS (high)

Issue. When archiving files, zipFiles() opened each source and only then fstat-ed it to confirm a regular file. open(2) on a FIFO blocks until a writer appears, so the guard ran too late — a FIFO source (reachable via a source path an attacker influences, or a regular-file→FIFO TOCTOU) hung the call indefinitely and pinned a libuv threadpool thread; a handful stalls all fs in the process. The code comment even claimed the guard prevented this.

Solution. Open with O_NONBLOCK so the open returns promptly and the fstat/regular-file check can reject a FIFO, device, or socket before any read. O_NONBLOCK has no effect on a regular file's subsequent reads, so the happy path is unchanged.

3. ZipFile read racing close() — use-after-close / fd reuse (medium)

Issue. A ZipEntry read runs on a file descriptor shared with its ZipFile, but reads didn't participate in close()'s lifecycle. close() marked the handle closed and released the fd while a read was still in flight, so the read landed on a closed — or, once the OS reused the fd number, another file's — descriptor (surfacing as EBADF, or a cross-file read), contradicting the class's own invariant.

Solution. Track in-flight reads on the shared handle. close() now marks the handle closing (rejecting new reads immediately), waits for in-flight reads to finish on the still-open fd, and only then releases it. closeSync(), which cannot wait, refuses while an asynchronous read is outstanding.

4. Failed directory rewrite in add() leaves a corrupt archive (medium)

Issue. add()/addEntrySync() advanced the central-directory offset and adopted the new entry into memory before the final directory rewrite. If that rewrite failed (e.g. ENOSPC/EIO after the member bytes were already written), the in-memory state and the on-disk archive were left diverged and half-updated with no restore, corrupting the next add().

Solution. Wrap the rewrite: on failure, restore the previous offset and directory entry and rewrite the original directory back, leaving the archive and the handle exactly as they were before the call, then rethrow.


A note on streaming reads

The audit also flagged that contentIterator() applies no default size cap. On inspection this is already safe and needs no change: the streaming decoder hard-bounds output to the member's declared uncompressed size (ERR_ZIP_ENTRY_CORRUPT the moment it inflates past it), and that size is exposed as entry.size before a byte is streamed — so it's a ceiling a consumer can inspect and trust. Rather than bolt on an arbitrary global cap (which would break legitimate large streams), this PR adds a regression test locking in that declared-size guard.

Testing

  • New regression tests: test-zlib-zip-security-hardening.js (header confusion, FIFO hang, streaming bound) and test-zlib-zip-file-lifecycle.js (close-vs-read, add rollback), the latter using deterministic fs.writeSync failure injection.
  • The full test/parallel/test-zlib* suite passes, including the updated test-zlib-zip-hardening.js / test-zlib-zip-coverage.js.

Commits

Structured as test-then-fix per issue:

test: cover ZIP header-confusion and DoS guards
zlib: reject local/central ZIP header mismatch
zlib: do not hang archiving a FIFO or device
test: cover ZIP fd lifecycle and add rollback
zlib: let a ZipFile read finish before close()
zlib: roll back a failed add() directory rewrite

Add regression tests for node:zlib ZIP hardening:

- A local file header that disagrees with the central directory on
  compression method, sizes, CRC, or the encryption flag lets another
  ZIP reader extract a different member from the same archive; such an
  archive must be rejected (fixed in a follow-up commit).
- zipFiles() must reject a FIFO/special source rather than block forever
  on open() (fixed in a follow-up commit).
- Streaming (contentIterator) is hard-bounded by the header's declared
  uncompressed size and rejects a member that inflates past it, so
  entry.size is a ceiling a consumer can trust up front; lock that in.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
The reader treats the central directory as authoritative for a member's
method, sizes, CRC, and name, but read the local header only for its
signature. An archive whose local file header disagrees with the
central directory therefore extracts different bytes here than in a
reader that uses the local header (e.g. Info-ZIP unzip), and its
encrypted bit was read from the local header while its identity came
from the central directory - a parser-confusion split that defeats
inspect-then-consume pipelines and can slip an encrypted member past a
central-directory scanner.

Cross-check the local header against the central entry when a member is
read: method, CRC, and both sizes (exempting a data-descriptor entry,
whose local crc/sizes are legitimately zero), plus the encryption flag.
Reject a mismatch with ERR_ZIP_INVALID_ARCHIVE, consistent with the
reject-rather-than-silently-choose stance already taken for ambiguous
archive ends.

The existing hardening/coverage tests that forged a decode-time size,
CRC, or Zip64 lie in the central header alone now trip this earlier
check; update them to patch both headers consistently so they still
exercise the decode-time guards they target.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
zipFiles() opened each source before fstat-ing it, so open() on a FIFO
(or a slow/blocking device) blocked indefinitely - the regular-file
guard ran too late to prevent it, and each stuck open pinned a libuv
threadpool thread. Open with O_NONBLOCK so the open returns promptly and
the fstat can reject anything that is not a regular file; O_NONBLOCK has
no effect on a regular file's subsequent reads.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
Add regression tests for two node:zlib ZipFile robustness issues:

- A read in flight when close() is called must complete on a live
  descriptor; close() must not release the fd out from under it (which
  surfaces as EBADF, or an OS-reused-fd cross-file read).
- If the central-directory rewrite fails after add() has written the
  member bytes, both the in-memory state and the on-disk archive must be
  rolled back, not left half-updated.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
An entry read runs on a file descriptor shared with its ZipFile, but
reads did not take part in close()'s lifecycle: close() marked the
handle closed and released the fd while a read was still in flight, so
the read landed on a closed - or worse, an OS-reused - descriptor
(surfacing as EBADF, or a cross-file read once the number was
reclaimed), despite the class comment promising otherwise.

Track in-flight reads on the shared handle. close() now marks the handle
closing (rejecting new reads at once), waits for the in-flight reads to
finish on the still-open fd, and only then closes it; closeSync(), which
cannot wait, refuses while an asynchronous read is outstanding.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
add()/addEntrySync() advanced the central-directory offset and adopted
the new entry into memory before the final directory rewrite. If that
rewrite failed (ENOSPC/EIO after the member bytes were already written),
the in-memory state and the on-disk archive were left diverged and
half-updated, with no restore, corrupting the next add().

Wrap the rewrite: on failure, restore the previous offset and directory
entry and rewrite the original directory back, leaving the archive and
handle exactly as before the call, then rethrow.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
@nodejs-github-bot nodejs-github-bot added the needs-ci PRs that need a full CI run. label Aug 4, 2026

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@richardlau richardlau added commit-queue-rebase Add this label to allow the Commit Queue to land a PR in several commits. request-ci Add this label to start a Jenkins CI on a PR. labels Aug 4, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 4, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.65385% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.28%. Comparing base (31cde9f) to head (719c83c).
⚠️ Report is 37 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/zip/file.js 58.90% 30 Missing ⚠️
lib/internal/zip/entry.js 94.28% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65016      +/-   ##
==========================================
- Coverage   90.30%   90.28%   -0.02%     
==========================================
  Files         759      759              
  Lines      247621   247799     +178     
  Branches    46672    46727      +55     
==========================================
+ Hits       223603   223721     +118     
- Misses      15473    15551      +78     
+ Partials     8545     8527      -18     
Files with missing lines Coverage Δ
lib/internal/zip/archive.js 99.69% <100.00%> (+0.31%) ⬆️
lib/internal/zip/headers.js 97.52% <100.00%> (+0.26%) ⬆️
lib/internal/zip/entry.js 98.11% <94.28%> (-0.33%) ⬇️
lib/internal/zip/file.js 93.09% <58.90%> (-3.32%) ⬇️

... and 44 files with indirect coverage changes

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

@pipobscure

Copy link
Copy Markdown
Contributor Author

This goes together with #65002 and #65007 which fix similar issues. The three should be merged soonish so as to preclude unhardened release.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@pipobscure

Copy link
Copy Markdown
Contributor Author

The CI seems stuck. And I can’t look at what failed. If there is something I can do, please ping me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commit-queue-rebase Add this label to allow the Commit Queue to land a PR in several commits. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants