Summary
compressing's extraction (compressing.{tar,tgz,zip}.uncompress, all via the shared makeUncompressFn in lib/utils.js) validates each entry's destination with isRealPathSafe() — a recursive lstat walk that follows pre-existing symlinks on disk and rejects any that escape the extraction root (the CVE-2026-40931 fix). The walk's dangling-symlink branch validates only the first hop's textual target and returns, so it never follows a multi-hop chain whose first hop stays inside the root but a later hop escapes.
With a pre-existing chain dest/a → dest/b (inside root) and dest/b → /outside (target missing), isRealPathSafe('dest/a') returns true (it only checks dest/a's immediate target dest/b), the check passes, and the library then writes a top-level file entry named a via createWriteStream('dest/a'), which follows the full chain and O_CREATs a file at /outside (whose parent directory exists) — outside the extraction directory.
Root cause
lib/utils.js, isRealPathSafe() (lines 33-72), the dangling branch (51-55):
const stat = await fs.promises.lstat(current);
if (stat.isSymbolicLink()) {
let resolved;
try {
resolved = await fs.promises.realpath(current); // follows the WHOLE chain
} catch (e) {
if (e.code === 'ENOENT') {
// Dangling symlink - check textual target
const linkTarget = await fs.promises.readlink(current); // only the FIRST hop
const absTarget = path.resolve(path.dirname(current), linkTarget);
return isWithinParent(absTarget); // ← returns; never follows further
}
return false;
}
if (!isWithinParent(resolved)) return false;
current = resolved;
}
When the chain is fully resolvable, realpath follows it and the result is checked (so a resolvable escape is caught — the CONTROL case below also shows a simple dangling 1-hop escape is caught because its textual target is outside). But when the chain is dangling (final target missing), the branch reads only the immediate readlink target and returns based on that single hop. A chain whose first hop is inside the root therefore passes, even though a subsequent hop points outside. The write path (fs.createWriteStream → open(O_CREAT)) then follows the full chain and creates the file at the outside target.
Proof of concept
repro/ drives the real compressing@2.1.1. node repro/poc.cjs (exit 0):
CONTROL 1-hop dest/x -> /outside (entry "x")
lib caught it (warning): YES | escaped: no [fix works for the simple case]
ATTACK 2-hop dest/a -> dest/b -> /outside (entry "a")
lib caught it (warning): no | escaped: YES
-> file CREATED OUTSIDE dest: .../OUTSIDE_DEST content: OWNED-by-attack_2hop
The CONTROL confirms the 2.1.1 fix catches simple single-hop directory poisoning; the ATTACK shows the 2-hop dangling chain passes the check (no "Skipping" warning) and a file is created outside the extraction directory. tar, tgz, and zip all route through utils.makeUncompressFn (lib/{tar,tgz,zip}/index.js:13), so all three are affected.
Impact
An attacker who can place pre-existing symlinks in the directory an application extracts an archive into — the supply-chain-via-git clone vector established by CVE-2026-40931 (git preserves committed symlinks) — can cause compressing.{tar,tgz,zip}.uncompress to create attacker-content files outside the extraction root. Creating files at attacker-chosen locations (e.g. shell rc files, cron entries, config/autoload files in watched directories) outside the intended sandbox is an arbitrary-file-write primitive that commonly leads to code execution.
Scope / honest limitations
- Creation, not overwrite. The bypass requires the final target to not exist at validation time (so
realpath throws ENOENT → the dangling branch runs). An existing target is resolved by realpath and correctly rejected. So this writes new files outside the root; it cannot overwrite existing ones via this path.
- Top-level file entry. The malicious entry must be a file at the chain head (e.g.
a); a nested entry (a/evil) fails earlier on mkdir through the dangling chain.
- Requires the pre-existing 2-hop chain (directory poisoning) — consistent with the accepted CVE-2026-40931 threat model.
Suggested fix
In the dangling-symlink branch, do not return on the immediate target: resolve and validate the entire symlink chain (e.g. iteratively readlink + re-validate each hop until a non-symlink or a confirmed-outside target), or simply fail closed when any path component is a symlink that does not fully resolve inside the root. Additionally, perform the write with O_NOFOLLOW (open the final component without following a symlink) / lstat-then-write so the physical write cannot follow a chain the validation didn't, eliminating the validate-vs-write divergence entirely.
Disclosure
GitHub private vulnerability reporting on node-modules/compressing (the project files its advisories there; no email is published in SECURITY.md).
References
Summary
compressing's extraction (compressing.{tar,tgz,zip}.uncompress, all via the sharedmakeUncompressFninlib/utils.js) validates each entry's destination withisRealPathSafe()— a recursivelstatwalk that follows pre-existing symlinks on disk and rejects any that escape the extraction root (the CVE-2026-40931 fix). The walk's dangling-symlink branch validates only the first hop's textual target and returns, so it never follows a multi-hop chain whose first hop stays inside the root but a later hop escapes.With a pre-existing chain
dest/a → dest/b(inside root) anddest/b → /outside(target missing),isRealPathSafe('dest/a')returnstrue(it only checksdest/a's immediate targetdest/b), the check passes, and the library then writes a top-level file entry namedaviacreateWriteStream('dest/a'), which follows the full chain andO_CREATs a file at/outside(whose parent directory exists) — outside the extraction directory.Root cause
lib/utils.js,isRealPathSafe()(lines 33-72), the dangling branch (51-55):When the chain is fully resolvable,
realpathfollows it and the result is checked (so a resolvable escape is caught — the CONTROL case below also shows a simple dangling 1-hop escape is caught because its textual target is outside). But when the chain is dangling (final target missing), the branch reads only the immediatereadlinktarget and returns based on that single hop. A chain whose first hop is inside the root therefore passes, even though a subsequent hop points outside. The write path (fs.createWriteStream→open(O_CREAT)) then follows the full chain and creates the file at the outside target.Proof of concept
repro/drives the realcompressing@2.1.1.node repro/poc.cjs(exit 0):The CONTROL confirms the 2.1.1 fix catches simple single-hop directory poisoning; the ATTACK shows the 2-hop dangling chain passes the check (no "Skipping" warning) and a file is created outside the extraction directory.
tar,tgz, andzipall route throughutils.makeUncompressFn(lib/{tar,tgz,zip}/index.js:13), so all three are affected.Impact
An attacker who can place pre-existing symlinks in the directory an application extracts an archive into — the supply-chain-via-
git clonevector established by CVE-2026-40931 (git preserves committed symlinks) — can causecompressing.{tar,tgz,zip}.uncompressto create attacker-content files outside the extraction root. Creating files at attacker-chosen locations (e.g. shell rc files, cron entries, config/autoload files in watched directories) outside the intended sandbox is an arbitrary-file-write primitive that commonly leads to code execution.Scope / honest limitations
realpaththrows ENOENT → the dangling branch runs). An existing target is resolved byrealpathand correctly rejected. So this writes new files outside the root; it cannot overwrite existing ones via this path.a); a nested entry (a/evil) fails earlier onmkdirthrough the dangling chain.Suggested fix
In the dangling-symlink branch, do not return on the immediate target: resolve and validate the entire symlink chain (e.g. iteratively
readlink+ re-validate each hop until a non-symlink or a confirmed-outside target), or simply fail closed when any path component is a symlink that does not fully resolve inside the root. Additionally, perform the write withO_NOFOLLOW(open the final component without following a symlink) /lstat-then-write so the physical write cannot follow a chain the validation didn't, eliminating the validate-vs-write divergence entirely.Disclosure
GitHub private vulnerability reporting on
node-modules/compressing(the project files its advisories there; no email is published in SECURITY.md).References
lib/utils.js(isRealPathSafe:33-72, dangling branch :51-55;makeUncompressFnentry handler :180-232);lib/{tar,tgz,zip}/index.js:13