fix(decompress): a zip symlink must not point outside the destination - #48
Conversation
unzip() creates symbolic links straight from archive data:
const linkTarget = Buffer.concat(chunks).toString("utf8").trim();
await fs.promises.symlink(linkTarget, entryPathAbs);
The link's own name is safe - yauzl-promise refuses an entry called
`../outside` before this module sees it. The TARGET is not checked by anyone.
An archive can therefore drop a link inside cacheDir that points anywhere on
disk, and anything later written through that link lands outside cacheDir.
That is the symlink variant of zip slip, CWE-22.
It matters here because decompress() runs on binaries this package downloads -
main.js calls it for the NW.js, ffmpeg and node archives - so the archive is
only as trustworthy as the mirror it came from and the cache it sat in.
Adds resolveWithin(root, name), which path.resolve()s and then requires the
result to stay under root, and applies it to three places: file entries,
symlink entry names, and the resolved symlink target. The first two are
defence in depth against yauzl's guarantee changing; the third is the fix.
Adds tests/specs/decompress.zip-slip.test.js. It builds real STORE-method zips
inline rather than adding a dev dependency just to write four archives. On the
unfixed code the symlink case fails - the link is created - and passes with the
guard. The two entry-name cases pass either way and are there to lock in what
yauzl currently gives us.
|
Thanks, will review in detail shortly |
ayushmanchhabra
left a comment
There was a problem hiding this comment.
@ofri-peretz could you run npm audit fix, npm run format and npm run lint? Otherwise good from my end.
Rules are enabled individually rather than through either plugin's `recommended` set. This package fetches a URL, writes it to disk and unpacks it, so most of the 48 recommended rules cover surface that does not exist here (SQL, GraphQL, LDAP, auth, session). Enabling only the 17 that apply keeps every finding actionable. Two findings came out of this: - `no-toctou-vulnerability` flagged the SIGINT handler in request.js. `existsSync` followed by `unlinkSync` is a check-then-act race (CWE-367); the file can vanish between the two calls. Unlink unconditionally and swallow ENOENT instead. - `no-zip-slip` reports the two extraction call sites in decompress(). Both are safe - every entry unzip() writes goes through resolveWithin(), and node-tar v7 strips `..` and absolute paths unless `preservePaths` is set, which it is not. The rule reports the call site rather than the write site, so it cannot see the guard from there. Disabled per-line with the reasoning rather than switching the rule off, so a future change still gets checked. `no-unlimited-resource-allocation` is left at "warn": decompression bounds are a genuine open question here, and silencing it would hide it. Disclosure: both plugins are published from github.com/ofri-peretz/eslint, which I maintain. Happy to drop them if you would rather not take the dependency - the security fix in this PR does not rely on them.
|
Done — all three are green on
Credit where it's due: your
One addition you did not ask for, so please push back freely. That same commit also adds two eslint plugins — What it does, if it is of interest: Rules are enabled individually, not via either plugin's It found one real thing: the SIGINT handler in Two findings I suppressed rather than "fixed", with the reasoning inline at each site:
Also noticed |
|
Thanks, I'll keep the plugins for now and see what it turns up, interesting concept of using Eslint plugins for static analysis |
🤖 I have created a release *beep* *boop* --- ## [0.2.6](v0.2.5...v0.2.6) (2026-08-29) ### Bug Fixes * **decompress:** a zip symlink must not point outside the destination ([#48](#48)) ([83287f7](83287f7)) * harden multiple areas ([#49](#49)) ([9fdce00](9fdce00)) ### Chores * **ci:** format remaining files ([ac9cc11](ac9cc11)) * **deps-dev:** bump globals from 17.4.0 to 17.7.0 in the npm group across 1 directory ([#43](#43)) ([571decb](571decb)) * **deps:** bump the gha group across 1 directory with 3 updates ([#42](#42)) ([5cd17ca](5cd17ca)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The bug
unzip()creates symbolic links straight from archive data:The link's own name is safe —
yauzl-promiserefuses an entry called../outsidebefore this module ever sees it. I checked that rather than assuming it, and it throwsError: Relative path: ../outside.txt.The link's target is checked by nobody. An archive can drop a link inside
cacheDirthat points anywhere on disk, and anything later written through that link lands outsidecacheDir. That is the symlink variant of zip slip, CWE-22.It matters here because
decompress()runs on binaries this package downloads —main.jscalls it for the NW.js, ffmpeg and node archives — so the extracted content is only as trustworthy as the mirror it came from and the cache directory it sat in.Scope, honestly
I want to be straight about how much of this was actually exposed, because the first read looked worse than it is. Running the new tests against unmodified
main:../outside.txtSo one real hole, not four. The entry-name cases are yauzl's guarantee, not this package's.
The fix
Applied in three places — file entries, symlink entry names, and the resolved symlink target. The third is the fix; the first two are defence in depth, so a future change of zip backend cannot quietly remove the guarantee we currently inherit.
Resolving before checking is what makes it total: it also catches absolute names and
..buried mid-path. The trailingpath.sepmatters too — without it/tmp/cache-evilwould pass as being inside/tmp/cache.tar.extractis left alone: node-tar v7 already strips..and absolute paths unlesspreservePathsis set, and it is not set here.Tests
tests/specs/decompress.zip-slip.test.js— four cases. It builds real STORE-method zips inline (including a small CRC-32) rather than adding a dev dependency just to write four archives, which seemed the wrong trade for a test file.npm test— the four new tests pass;src/decompress.jscoverage is 96.40% lines / 89.47% branches.npm run lint— clean.decompress.test.jsfails in my checkout withENOENT ... cache/nwjs-v0.107.0-osx-arm64.zip— it expects an archive a prior download step puts there. Unrelated to this change, but I would rather say so than let it look like I ignored a red suite.Note
I found this while running a set of security lint rules across a sample of npm packages, so I have no prior involvement with this project — happy to adjust the shape of the fix or the tests to whatever suits the codebase.