Skip to content

Commit 9c885e5

Browse files
committed
fix: prevent symlink path traversal via pre-existing symlinks during tar extraction
The existing CVE-2026-24884 fix only validates paths using string-based path.resolve(), which doesn't detect pre-existing symlinks on disk. An attacker can pre-plant symlinks (e.g. via git clone) that cause archive extraction to write files to arbitrary locations outside the extraction directory. Add isRealPathSafe() which walks each path segment using fs.promises.lstat and rejects entries where any segment is a symlink resolving outside the extraction root. Fail closed on unexpected filesystem errors. Closes GHSA-4c3q-x735-j3r5
1 parent fba303c commit 9c885e5

File tree

4 files changed

+349
-81
lines changed

4 files changed

+349
-81
lines changed

lib/utils.js

Lines changed: 109 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,57 @@ function isPathWithinParent(childPath, parentPath) {
2020
normalizedChild.startsWith(parentWithSep);
2121
}
2222

23+
/**
24+
* Check if the real filesystem path stays within parentDir,
25+
* accounting for pre-existing symlinks on disk.
26+
* Walks each path segment from parentDir to targetPath using lstat.
27+
* If any segment is a symlink, resolves it and verifies it stays within parentDir.
28+
* @param {string} targetPath - Absolute path to validate
29+
* @param {string} parentDir - Absolute path of the extraction root
30+
* @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS)
31+
* @returns {Promise<boolean>} true if safe, false if any segment escapes via symlink
32+
*/
33+
async function isRealPathSafe(targetPath, parentDir, realParentDir) {
34+
function isWithinParent(p) {
35+
return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir);
36+
}
37+
38+
const relative = path.relative(parentDir, targetPath);
39+
const segments = relative.split(path.sep);
40+
let current = parentDir;
41+
for (const segment of segments) {
42+
if (!segment || segment === '.') continue;
43+
current = path.join(current, segment);
44+
try {
45+
const stat = await fs.promises.lstat(current);
46+
if (stat.isSymbolicLink()) {
47+
let resolved;
48+
try {
49+
resolved = await fs.promises.realpath(current);
50+
} catch (e) {
51+
if (e.code === 'ENOENT') {
52+
// Dangling symlink - check textual target
53+
const linkTarget = await fs.promises.readlink(current);
54+
const absTarget = path.resolve(path.dirname(current), linkTarget);
55+
return isWithinParent(absTarget);
56+
}
57+
// Fail closed: unexpected errors during symlink resolution are unsafe
58+
return false;
59+
}
60+
if (!isWithinParent(resolved)) {
61+
return false;
62+
}
63+
current = resolved;
64+
}
65+
} catch (e) {
66+
if (e.code === 'ENOENT') break; // Path doesn't exist yet, safe
67+
// Fail closed: unexpected filesystem errors are unsafe
68+
return false;
69+
}
70+
}
71+
return true;
72+
}
73+
2374
// file/fileBuffer/stream
2475
exports.sourceType = source => {
2576
if (!source) return undefined;
@@ -114,6 +165,9 @@ exports.makeUncompressFn = StreamClass => {
114165

115166
// Resolve destDir to absolute path for security validation
116167
const resolvedDestDir = path.resolve(destDir);
168+
// Resolve once for the entire extraction to handle OS-level symlinks
169+
// (e.g. /var -> /private/var on macOS)
170+
const realDestDirPromise = fs.promises.realpath(resolvedDestDir).catch(() => resolvedDestDir);
117171

118172
let entryCount = 0;
119173
let successCount = 0;
@@ -123,6 +177,60 @@ exports.makeUncompressFn = StreamClass => {
123177
if (isFinish && entryCount === successCount) resolve();
124178
}
125179

180+
async function processEntry(header, stream) {
181+
const destFilePath = path.join(resolvedDestDir, stripFileName(strip, header.name, header.type));
182+
const resolvedDestPath = path.resolve(destFilePath);
183+
184+
if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
185+
console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
186+
stream.resume();
187+
return;
188+
}
189+
190+
const realDestDir = await realDestDirPromise;
191+
if (!await isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir)) {
192+
console.warn(`[compressing] Skipping entry "${header.name}": a symlink in its path resolves outside the extraction directory`);
193+
stream.resume();
194+
return;
195+
}
196+
197+
if (header.type === 'file') {
198+
const dir = path.dirname(destFilePath);
199+
await fs.promises.mkdir(dir, { recursive: true });
200+
entryCount++;
201+
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
202+
if (err) return reject(err);
203+
successCount++;
204+
done();
205+
});
206+
} else if (header.type === 'symlink') {
207+
const dir = path.dirname(destFilePath);
208+
const target = path.resolve(dir, header.linkname);
209+
210+
if (!isPathWithinParent(target, resolvedDestDir)) {
211+
console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
212+
stream.resume();
213+
return;
214+
}
215+
216+
if (!await isRealPathSafe(target, resolvedDestDir, realDestDir)) {
217+
console.warn(`[compressing] Skipping symlink "${header.name}": target resolves outside extraction directory via existing symlink`);
218+
stream.resume();
219+
return;
220+
}
221+
222+
entryCount++;
223+
await fs.promises.mkdir(dir, { recursive: true });
224+
const relativeTarget = path.relative(dir, target);
225+
await fs.promises.symlink(relativeTarget, destFilePath);
226+
successCount++;
227+
stream.resume();
228+
} else { // directory
229+
await fs.promises.mkdir(destFilePath, { recursive: true });
230+
stream.resume();
231+
}
232+
}
233+
126234
new StreamClass(opts)
127235
.on('finish', () => {
128236
isFinish = true;
@@ -131,57 +239,7 @@ exports.makeUncompressFn = StreamClass => {
131239
.on('error', reject)
132240
.on('entry', (header, stream, next) => {
133241
stream.on('end', next);
134-
const destFilePath = path.join(resolvedDestDir, stripFileName(strip, header.name, header.type));
135-
const resolvedDestPath = path.resolve(destFilePath);
136-
137-
// Security: Validate that the entry path doesn't escape the destination directory
138-
if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
139-
console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
140-
stream.resume();
141-
return;
142-
}
143-
144-
if (header.type === 'file') {
145-
const dir = path.dirname(destFilePath);
146-
fs.mkdir(dir, { recursive: true }, err => {
147-
if (err) return reject(err);
148-
149-
entryCount++;
150-
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
151-
if (err) return reject(err);
152-
successCount++;
153-
done();
154-
});
155-
});
156-
} else if (header.type === 'symlink') {
157-
const dir = path.dirname(destFilePath);
158-
const target = path.resolve(dir, header.linkname);
159-
160-
// Security: Validate that the symlink target doesn't escape the destination directory
161-
if (!isPathWithinParent(target, resolvedDestDir)) {
162-
console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
163-
stream.resume();
164-
return;
165-
}
166-
167-
entryCount++;
168-
169-
fs.mkdir(dir, { recursive: true }, err => {
170-
if (err) return reject(err);
171-
172-
const relativeTarget = path.relative(dir, target);
173-
fs.symlink(relativeTarget, destFilePath, err => {
174-
if (err) return reject(err);
175-
successCount++;
176-
stream.resume();
177-
});
178-
});
179-
} else { // directory
180-
fs.mkdir(destFilePath, { recursive: true }, err => {
181-
if (err) return reject(err);
182-
stream.resume();
183-
});
184-
}
242+
processEntry(header, stream).catch(reject);
185243
});
186244
});
187245
});
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const os = require('os');
5+
const path = require('path');
6+
const zlib = require('zlib');
7+
const uuid = require('uuid');
8+
const assert = require('assert');
9+
const compressing = require('../..');
10+
const { createTarBuffer } = require('../util');
11+
12+
describe('test/tar/security-GHSA-4c3q-x735-j3r5.test.js', () => {
13+
let tempDir;
14+
15+
beforeEach(() => {
16+
tempDir = path.join(os.tmpdir(), uuid.v4());
17+
fs.mkdirSync(tempDir, { recursive: true });
18+
});
19+
20+
afterEach(() => {
21+
fs.rmSync(tempDir, { recursive: true, force: true });
22+
});
23+
24+
function gzipBuffer(buf) {
25+
return new Promise((resolve, reject) => {
26+
zlib.gzip(buf, (err, result) => {
27+
if (err) return reject(err);
28+
resolve(result);
29+
});
30+
});
31+
}
32+
33+
describe('pre-existing symlink file pointing outside destDir', () => {
34+
it('should block file write through pre-existing symlink to external file', async () => {
35+
const destDir = path.join(tempDir, 'dest');
36+
const outsideDir = path.join(tempDir, 'outside');
37+
const sensitiveFile = path.join(outsideDir, 'target.txt');
38+
39+
// Setup: create the sensitive file and a pre-existing symlink in destDir
40+
fs.mkdirSync(outsideDir, { recursive: true });
41+
fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT');
42+
fs.mkdirSync(destDir, { recursive: true });
43+
fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file'));
44+
45+
// Create a tar with a regular file entry matching the symlink name
46+
const tarBuffer = await createTarBuffer([
47+
{ name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' },
48+
]);
49+
50+
await compressing.tar.uncompress(tarBuffer, destDir);
51+
52+
// The sensitive file should NOT have been overwritten
53+
assert.strictEqual(
54+
fs.readFileSync(sensitiveFile, 'utf8'),
55+
'ORIGINAL_SAFE_CONTENT',
56+
'Sensitive file should not be overwritten through pre-existing symlink'
57+
);
58+
});
59+
});
60+
61+
describe('pre-existing symlink directory pointing outside destDir', () => {
62+
it('should block file write through pre-existing symlink directory', async () => {
63+
const destDir = path.join(tempDir, 'dest');
64+
const outsideDir = path.join(tempDir, 'outside');
65+
66+
// Setup: create outside dir and a symlink directory in destDir
67+
fs.mkdirSync(outsideDir, { recursive: true });
68+
fs.mkdirSync(destDir, { recursive: true });
69+
fs.symlinkSync(outsideDir, path.join(destDir, 'subdir'));
70+
71+
// Create a tar with a file inside the symlink directory
72+
const tarBuffer = await createTarBuffer([
73+
{ name: 'subdir/secret.txt', type: 'file', content: 'MALICIOUS_DATA' },
74+
]);
75+
76+
await compressing.tar.uncompress(tarBuffer, destDir);
77+
78+
// The file should NOT exist in the outside directory
79+
assert.strictEqual(
80+
fs.existsSync(path.join(outsideDir, 'secret.txt')),
81+
false,
82+
'File should not be written through pre-existing symlink directory'
83+
);
84+
});
85+
});
86+
87+
describe('deeply nested pre-existing symlink', () => {
88+
it('should block file write through nested symlink escape', async () => {
89+
const destDir = path.join(tempDir, 'dest');
90+
const outsideDir = path.join(tempDir, 'outside');
91+
92+
// Setup: create real directories and a symlink deep in the tree
93+
fs.mkdirSync(path.join(destDir, 'a', 'b'), { recursive: true });
94+
fs.mkdirSync(outsideDir, { recursive: true });
95+
fs.symlinkSync(outsideDir, path.join(destDir, 'a', 'b', 'c'));
96+
97+
// Create a tar with a file through the deep symlink
98+
const tarBuffer = await createTarBuffer([
99+
{ name: 'a/b/c/file.txt', type: 'file', content: 'ESCAPED_DATA' },
100+
]);
101+
102+
await compressing.tar.uncompress(tarBuffer, destDir);
103+
104+
// The file should NOT exist in the outside directory
105+
assert.strictEqual(
106+
fs.existsSync(path.join(outsideDir, 'file.txt')),
107+
false,
108+
'File should not be written through deeply nested symlink'
109+
);
110+
});
111+
});
112+
113+
describe('pre-existing symlink pointing within destDir (should be allowed)', () => {
114+
it('should allow file write through symlink that stays within destDir', async () => {
115+
const destDir = path.join(tempDir, 'dest');
116+
const realDir = path.join(destDir, 'real');
117+
118+
// Setup: create real directory and internal symlink
119+
fs.mkdirSync(realDir, { recursive: true });
120+
fs.symlinkSync(realDir, path.join(destDir, 'link'));
121+
122+
// Create a tar with a file through the internal symlink
123+
const tarBuffer = await createTarBuffer([
124+
{ name: 'link/newfile.txt', type: 'file', content: 'safe content' },
125+
]);
126+
127+
await compressing.tar.uncompress(tarBuffer, destDir);
128+
129+
// The file SHOULD exist since the symlink points within destDir
130+
assert.strictEqual(
131+
fs.readFileSync(path.join(realDir, 'newfile.txt'), 'utf8'),
132+
'safe content',
133+
'File should be written through internal symlink'
134+
);
135+
});
136+
});
137+
138+
describe('directory entry through pre-existing external symlink', () => {
139+
it('should block directory creation through pre-existing symlink', async () => {
140+
const destDir = path.join(tempDir, 'dest');
141+
const outsideDir = path.join(tempDir, 'outside');
142+
143+
// Setup
144+
fs.mkdirSync(outsideDir, { recursive: true });
145+
fs.mkdirSync(destDir, { recursive: true });
146+
fs.symlinkSync(outsideDir, path.join(destDir, 'escape'));
147+
148+
// Create a tar with a directory entry through the symlink
149+
const tarBuffer = await createTarBuffer([
150+
{ name: 'escape/newdir/', type: 'directory' },
151+
]);
152+
153+
await compressing.tar.uncompress(tarBuffer, destDir);
154+
155+
// The directory should NOT exist in the outside directory
156+
assert.strictEqual(
157+
fs.existsSync(path.join(outsideDir, 'newdir')),
158+
false,
159+
'Directory should not be created through pre-existing symlink'
160+
);
161+
});
162+
});
163+
164+
describe('tgz format shares the same protection', () => {
165+
it('should block file write through pre-existing symlink in tgz extraction', async () => {
166+
const destDir = path.join(tempDir, 'dest');
167+
const outsideDir = path.join(tempDir, 'outside');
168+
const sensitiveFile = path.join(outsideDir, 'target.txt');
169+
170+
// Setup
171+
fs.mkdirSync(outsideDir, { recursive: true });
172+
fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT');
173+
fs.mkdirSync(destDir, { recursive: true });
174+
fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file'));
175+
176+
// Create a tgz buffer
177+
const tarBuffer = await createTarBuffer([
178+
{ name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' },
179+
]);
180+
const tgzBuffer = await gzipBuffer(tarBuffer);
181+
182+
await compressing.tgz.uncompress(tgzBuffer, destDir);
183+
184+
// The sensitive file should NOT have been overwritten
185+
assert.strictEqual(
186+
fs.readFileSync(sensitiveFile, 'utf8'),
187+
'ORIGINAL_SAFE_CONTENT',
188+
'TGZ: Sensitive file should not be overwritten through pre-existing symlink'
189+
);
190+
});
191+
});
192+
193+
describe('normal extraction still works (regression)', () => {
194+
it('should extract files normally when no pre-existing symlinks', async () => {
195+
const destDir = path.join(tempDir, 'dest');
196+
197+
const tarBuffer = await createTarBuffer([
198+
{ name: 'file1.txt', type: 'file', content: 'content1' },
199+
{ name: 'subdir/', type: 'directory' },
200+
{ name: 'subdir/file2.txt', type: 'file', content: 'content2' },
201+
]);
202+
203+
await compressing.tar.uncompress(tarBuffer, destDir);
204+
205+
assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1');
206+
assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2');
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)