CVE-2026-101894: Symlink-Chain Path Traversal in @xhmikosr/decompress
1. Introduction
The @xhmikosr/decompress package extracts tar, tar.gz, tar.bz2, and ZIP archives in Node.js applications. CVE-2026-101894 is a symlink-chain path traversal in its default decompress(input, output) API: crafted links can make an archive entry resolve outside the selected output directory.
The tested chain is accepted on 11.1.3 and 10.2.1 and rejected on 11.1.4 and 10.2.2. In the supplied 300-attempt sessions, the WP09 archive produced an extraction-time outside file write on both vulnerable releases and none on either fixed release. A directory-only variant created an empty outside directory on both vulnerable releases. The later consumer write and M3 hardlink read are separate effects.
2. @xhmikosr/decompress Description
@xhmikosr/decompress is a Node.js archive-extraction package and a maintained fork of decompress. Its default API extracts archive entries to a selected output directory and includes checks for entry paths, symlink targets, and hardlink targets.
3. Vulnerability Severity
| CVE ID | CVE-2026-101894 (@xhmikosr/decompress) |
|---|---|
| Severity | CRITICAL |
| CVSS v3.1 Score | 9.1 / 10 |
| CVSS v3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-22, CWE-59 |
| Fixed Versions | 11.1.4, 10.2.2 |
The GHSA assigns CVSS 9.1 with AC:L. A separately observed settled-chain diagnostic, not included in the embedded reproduction files, showed a realpath discrepancy after both links were fully created. The WP09 outside-write path observed here was race-dependent (19/300 attempts on 11.1.3 and 20/300 on 10.2.1). The observed race-trigger rate is hardware- and scheduler-sensitive and should not be read as a fixed probability; these lab counts do not independently validate the advisory’s AC rating.
4. Scope of Impact
GHSA affected ranges: @xhmikosr/decompress 11.0.0 through 11.1.3; 10.2.1 and earlier. Directly tested here only at 11.1.3 and 10.2.1.
Comparisons were 11.1.3 with 11.1.4 and 10.2.1 with 10.2.2; other releases within the GHSA ranges were not directly tested. The GHSA also states that the separate, unmaintained package named decompress shares this flaw, is affected through 4.2.1, and has no patched release.
Fixed: @xhmikosr/decompress 11.1.4 and 10.2.2
5. Where is the vulnerability present?
In the affected code, ensureLinkTargetInsideOutput() in index.js checks symlink targets using a lexically resolved path, then calls realpath() on that result to check for an existing target that escapes the output directory. This lexical-plus-realpath guard was the hardening for CVE-2026-53486; the symlink chain here bypasses it.
The bypass depends on two parts of the check:
- In the upstream maintainer’s regression fixture—not the archives used in this article—link entry b has target a/../secret.txt. The check resolves that target lexically from b’s containing directory. path.resolve(output, ‘a/../secret.txt’) yields output/secret.txt, which appears to remain inside. This normalization removes a before realpath() can follow the planted symlink.
- In that fixture and in the article’s chain-only archive, the target file is absent. realpath() returns ENOENT, and the vulnerable fallback accepts the lexical result. This is the symlink-planting path. WP09 instead uses b -> a/..; its outside file write depends on the later file-parent check and extraction ordering described below.
Here, a points to the output directory (a -> .). When the kernel follows a and then processes .., it reaches the parent of the output directory. The chain-only archive plants b but contains no regular file beneath it, so it does not itself create an outside marker during extraction.
In 11.1.3, non-hardlink entries are extracted concurrently. The wp09 archive contains the chain a -> ., b -> a/.., followed by a regular file at b/wp09.txt.
Node’s callback-style fs.realpath() uses the legacy JavaScript directory-walk implementation, while fs.promises.realpath() uses Node’s native realpath implementation; Node documents the promise API as matching fs.realpath.native(). This resolver split was observed in a separate Node v22.23.3 diagnostic that is not included in the embedded reproduction files; other Node releases were not tested. [email protected] forwards the callback API unchanged, so it is not the source of the resolver difference. With b already planted, the callback-style containment check can report out/ while the native resolver follows b to out’s parent. For WP09, the archive extracts non-hardlink entries concurrently: the symlink must exist when the file-parent check runs for the archive file to follow b outside. If the real b directory is created first, symlink creation returns EEXIST and the file stays inside.
The captured run had no outside marker before extraction; after decompress() returned, the marker contained the archive entry’s payload. The capture proves the file appeared during extraction, but does not record the exact scheduling order for that attempt.
A separate, non-embedded quiescent diagnostic created a -> . and b -> a/.. before calling either API. With both links settled, graceful-fs.realpath(out/b) returned the output directory, while fs.promises.realpath(out/b) returned its parent. This observed resolver discrepancy is deterministic and does not depend on extraction concurrency. It explains the symlink-chain check’s incorrect containment result; WP09 adds a separate ordering race because the file-parent check may observe either the planted symlink b or a real directory b. The diagnostic’s script and transcript are not included in the embedded reproduction files.
The 11.x and 10.x fixes keep the lexical check as an early guard. In the fixed code, files and directories are extracted first, then symlinks, then hardlinks. Hardlink targets have a separate output-path check; symlinks that resolve outside are removed and rejected.
After link entries are processed, resolveMaybeMissing() realpaths the longest existing path prefix and appends missing components. In 11.1.4, realpath is imported from node:fs/promises; assertSymlinkResolvesInside() removes and rejects created symlinks whose resolved targets escape.
The 10.2.2 backport applies this protection to the tested 10.x release. The regression test exercises the self-referential chain.
The M3 payload plants a -> . and b -> a/../m3vdir, then hardlinks c -> b/canary.txt to a pre-existing outside canary on the same filesystem and owned by the same UID. On 11.1.3, extraction returns OK and out/c is present with the same device and inode as the canary (nlink=2); the verifier reads the canary content through out/c after extraction returns.
On 11.1.4, extraction refuses the escaping chain and out/c is absent. The supplied M3 transcript confirms the hardlink/read result on 11.1.3; this is a separate hardlink-check path, not the WP09 regular-file-parent ordering race. The transcript does not instrument the callback resolver on out/b/canary.txt, so the exact internal lookup for that M3 path is not directly captured here.
6. Risk
The chain-only archive plants b but writes no file outside. In the supplied 300-attempt sessions, the WP09 archive wrote a file outside during extraction in 19 attempts on 11.1.3 and 20 on 10.2.1, and in none of 300 attempts on 11.1.4 or 10.2.2. These are session observations, not rates or guarantees. When real b directory creation wins the race, symlink creation returns EEXIST and the entry stays inside. The directory-only archive made an empty outside sibling directory in 24 of 25 attempts on both vulnerable releases, with extraction then throwing ENOENT, and in none of 25 attempts on either fixed release; it wrote no file content. The directory probe asserts the raced outcome on every attempt, so it exits non-zero when one attempt loses that race. Its exit status is not a pass or fail signal; the per-run lines are the evidence. Results cover these payloads on Linux/arm64.
In M3, extraction creates an output-side hardlink to a pre-existing file on the same filesystem, and the verifier reads it through out/c after decompress() returns. This is an outside-file read path, not the outside write shown in Figure 1.
The GHSA warns that overwriting startup scripts or configuration can lead to remote code execution. These reproductions demonstrate filesystem effects only; they do not show a consuming application loading or executing an overwritten file.
7. Mitigation
Upgrade to @xhmikosr/decompress 11.1.4 or 10.2.2, or a later fixed release. The fix validates created symlinks before extraction returns, attempts to unlink any whose resolved target escapes, and rejects the extraction.
No general workaround is available. Avoid extracting untrusted archives on affected versions. If upgrading is not immediately possible, validate archive entries out of band and reject any resolved path that escapes the destination. Treat this as temporary risk reduction, not a substitute for upgrading.
Run extraction with minimal filesystem permissions and without access to sensitive host paths. Isolation is defense in depth; it does not make the vulnerable package safe for untrusted archives.
For services that routinely process untrusted archives, use a dedicated low-privilege process with a restricted filesystem view and upgrade the extraction package.
8. Reproduction
Version Results
The six figures separate the observed effects. Figures 1-2 show a race-dependent WP09 extraction-time file write on 11.1.3 and the 11.1.4 session with no outside marker in 300 attempts. Figures 3-4 show chain-only symlink planting followed by a separate consumer write. Figures 5-6 show the M3 hardlink read path and the fixed-version refusal.

Figure 1. First marker-positive WP09 attempt in a 300-attempt session on 11.1.3 (run 36): the marker was absent before extraction and present afterward with the archive payload. This is an extraction-time outside write, produced by the embedded probe scripts/wp09evidence.mjs. Archive SHA-256: 95f1c084febf58c89002c9ba33f29c707d01456ac5dfb60751e04e6607040cf3.

Figure 2. On 11.1.4, the displayed final attempt returned EEXIST with b as a real directory; the closing summary reports no outside marker in the 300-attempt session. Produced by the embedded probe scripts/wp09evidence.mjs. Archive SHA-256: 95f1c084febf58c89002c9ba33f29c707d01456ac5dfb60751e04e6607040cf3.

Figure 3. On 11.1.3, extraction plants the escaping link but leaves the outside marker absent. The separately labeled consumer write through out/b creates the marker afterward; this is not an extraction-time write. Produced by the embedded scripts/chain-consumer.mjs. Archive SHA-256: 20b4452b4cf593df52566628bd7f9a164127258bbd426c65daf1590f39e472ce.

Figure 4. On 11.1.4, extraction rejects the same chain. The later consumer write creates a regular file inside the output directory; the outside marker remains absent. Produced by the embedded scripts/chain-consumer.mjs. Archive SHA-256: 20b4452b4cf593df52566628bd7f9a164127258bbd426c65daf1590f39e472ce.
M3 hardlink result. The archive creates out/c as a hardlink to a pre-existing outside canary. The verifier checks the shared inode and reads it after extraction returns; this is an outside-read result, not an extraction-time write.

Figure 5. M3 on 11.1.3: extraction creates out/c as a hardlink to the pre-existing outside canary; the verifier confirms the shared inode and reads it after decompress() returns. This is an outside-file read, not an outside write. Archive SHA-256: 27cde0cac9fbc38dcf9247fb2a2d232765e3f33ec3cfa79d8b2f16a4b64d2543.

Figure 6. On 11.1.4, extraction refuses the escaping chain and out/c is absent. The M3 archive is the same SHA-256 listed for Figure 5: 27cde0cac9fbc38dcf9247fb2a2d232765e3f33ec3cfa79d8b2f16a4b64d2543.
Separate directory-only test. The archive uses a -> ., b -> a/.., and a directory entry at b/sub. Its SHA-256 is bd4ce9c24177da357e9f7fa7e7f7115b9c16243d617ff66a03cfea30b7229d84. The embedded probe checks for the sibling directory before and after extraction. Result: the outside directory was created in 24 of 25 attempts on both 11.1.3 and 10.2.1, and in none of the 25 attempts on 11.1.4 or 10.2.2. No outside file content was written.
Reproduce from this document
The reproduction scripts and archives are embedded in this DOCX. Extract them and verify the archive hashes before running the tests.

Run the following shell block in Bash from the cve-lab directory created by the setup block. Each release uses a fresh pinned container and its npm ci lockfile. The chain case separates extraction from a later consumer write; the directory probe checks outside-directory creation. The file-write probe is race-dependent, so verify marker state rather than relying on process status alone. The directory probe records its exit status but does not gate the remaining probes. This article’s M3 comparison is limited to 11.1.3 and 11.1.4.
