Skip to content

Commit 72a3c84

Browse files
authored
fix: resolve symlink chains fully when extracting (#140)
`isRealPathSafe()` stopped walking as soon as `realpath()` failed on a dangling link, checking only that link's immediate target. A destination reached through several hops, or through a linked directory, was only partially resolved, so an entry could land somewhere the check had not accounted for. It now resolves the remaining hops itself, bounded by `MAX_SYMLINK_DEPTH` so a chain `realpath()` cannot see does not recurse without end. Behaviour change worth noting: a file entry landing on a symlink now replaces that link instead of writing through to whatever it points at. This matches tar(1), node-tar, tar-fs and libarchive. Where the platform has it, the write also opens with `O_NOFOLLOW`. Linked directories inside the extraction directory are still traversed, so entries beneath them land where they always did. Tests cover chains of two and three hops, chains through a linked directory, cycles, and the traversal case, across tar, tgz and zip. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved archive extraction safety when handling symbolic links. * Prevented symlink chains, cycles, and linked-directory paths from escaping the extraction destination. * Prevented extracted files from overwriting locations targeted by existing symbolic links. * Added protections against unsafe writes through symbolic links across TAR, TGZ, and ZIP archives. * **Tests** * Added comprehensive coverage for symlink resolution, traversal, cycles, and destination-link replacement scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0a77278 commit 72a3c84

3 files changed

Lines changed: 372 additions & 10 deletions

File tree

lib/utils.js

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ const fs = require('fs');
44
const path = require('path');
55
const { pipeline: pump } = require('stream');
66

7+
// Matches the kernel's own symlink chain limit closely enough to reject loops
8+
// that realpath() never sees, without rejecting any realistic layout.
9+
const MAX_SYMLINK_DEPTH = 32;
10+
11+
// Numeric flags are accepted here per the "File system flags" section of the fs
12+
// docs, the same way node:zip opens with O_NOFOLLOW. The flag makes open() fail
13+
// with ELOOP when the final component is a symlink, so the write never resolves
14+
// one. It is undefined on Windows, where unlinkSymlink() below does the work.
15+
const NO_FOLLOW_WRITE_FLAGS = typeof fs.constants.O_NOFOLLOW === 'number'
16+
? fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY
17+
: 'w';
18+
19+
/**
20+
* Remove a symlink sitting at the exact path an entry is about to be written to.
21+
* Extraction replaces such a link rather than writing through to whatever it
22+
* points at, which is how tar(1), node-tar and libarchive all behave.
23+
* @param {string} target - Absolute path of the entry destination
24+
*/
25+
async function unlinkSymlink(target) {
26+
try {
27+
const stat = await fs.promises.lstat(target);
28+
if (stat.isSymbolicLink()) await fs.promises.unlink(target);
29+
} catch (e) {
30+
if (e.code !== 'ENOENT') throw e;
31+
}
32+
}
33+
734
/**
835
* Check if childPath is within parentPath (prevents path traversal attacks)
936
* @param {string} childPath - The path to check
@@ -28,16 +55,33 @@ function isPathWithinParent(childPath, parentPath) {
2855
* @param {string} targetPath - Absolute path to validate
2956
* @param {string} parentDir - Absolute path of the extraction root
3057
* @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
58+
* @param {number} depth - Recursion depth when re-walking a dangling symlink's target
59+
* @return {Promise<boolean>} true if safe, false if any segment escapes via symlink
3260
*/
33-
async function isRealPathSafe(targetPath, parentDir, realParentDir) {
61+
async function isRealPathSafe(targetPath, parentDir, realParentDir, depth = 0) {
62+
// realpath() rejects long chains with ELOOP, but the dangling branch below resolves
63+
// hop by hop without the kernel's help, so it needs its own bound.
64+
if (depth >= MAX_SYMLINK_DEPTH) return false;
65+
3466
function isWithinParent(p) {
3567
return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir);
3668
}
3769

38-
const relative = path.relative(parentDir, targetPath);
70+
// A link target may be written in either namespace when the two differ, as with
71+
// /var -> /private/var on macOS. Walk from whichever root actually contains it,
72+
// or the relative path below would climb out through '..' and reject a safe link.
73+
let baseDir;
74+
if (isPathWithinParent(targetPath, parentDir)) {
75+
baseDir = parentDir;
76+
} else if (isPathWithinParent(targetPath, realParentDir)) {
77+
baseDir = realParentDir;
78+
} else {
79+
return false;
80+
}
81+
82+
const relative = path.relative(baseDir, targetPath);
3983
const segments = relative.split(path.sep);
40-
let current = parentDir;
84+
let current = baseDir;
4185
for (const segment of segments) {
4286
if (!segment || segment === '.') continue;
4387
current = path.join(current, segment);
@@ -49,10 +93,14 @@ async function isRealPathSafe(targetPath, parentDir, realParentDir) {
4993
resolved = await fs.promises.realpath(current);
5094
} catch (e) {
5195
if (e.code === 'ENOENT') {
52-
// Dangling symlink - check textual target
96+
// Dangling symlink: realpath() gave up, so resolve the textual target
97+
// ourselves. Checking the target string alone is not enough, because the
98+
// target may itself be a symlink, or sit under a directory that is one,
99+
// and both get resolved when the entry is actually written.
53100
const linkTarget = await fs.promises.readlink(current);
54101
const absTarget = path.resolve(path.dirname(current), linkTarget);
55-
return isWithinParent(absTarget);
102+
if (!isWithinParent(absTarget)) return false;
103+
return await isRealPathSafe(absTarget, parentDir, realParentDir, depth + 1);
56104
}
57105
// Fail closed: unexpected errors during symlink resolution are unsafe
58106
return false;
@@ -197,8 +245,12 @@ exports.makeUncompressFn = StreamClass => {
197245
if (header.type === 'file') {
198246
const dir = path.dirname(destFilePath);
199247
await fs.promises.mkdir(dir, { recursive: true });
248+
await unlinkSymlink(destFilePath);
200249
entryCount++;
201-
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
250+
pump(stream, fs.createWriteStream(destFilePath, {
251+
flags: NO_FOLLOW_WRITE_FLAGS,
252+
mode: opts.mode || header.mode,
253+
}), err => {
202254
if (err) return reject(err);
203255
successCount++;
204256
done();
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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, createZipBuffer } = require('../util');
11+
12+
// Extraction resolves a symlink chain hop by hop when realpath() cannot, so an
13+
// entry whose destination passes through several links still lands where the
14+
// resolved chain actually points, and never outside the extraction directory.
15+
describe('test/tar/symlink-resolution.test.js', () => {
16+
let tempDir;
17+
18+
beforeEach(() => {
19+
tempDir = path.join(os.tmpdir(), uuid.v4());
20+
fs.mkdirSync(tempDir, { recursive: true });
21+
});
22+
23+
afterEach(() => {
24+
fs.rmSync(tempDir, { recursive: true, force: true });
25+
});
26+
27+
function gzipBuffer(buf) {
28+
return new Promise((resolve, reject) => {
29+
zlib.gzip(buf, (err, result) => {
30+
if (err) return reject(err);
31+
resolve(result);
32+
});
33+
});
34+
}
35+
36+
// destDir/entry -> destDir/hop -> outsideDir/other.txt, which does not exist,
37+
// so realpath() cannot resolve the chain and each hop is walked by hand.
38+
function setupChain(destDir, outsideDir) {
39+
fs.mkdirSync(outsideDir, { recursive: true });
40+
fs.mkdirSync(destDir, { recursive: true });
41+
fs.symlinkSync(path.join(destDir, 'hop'), path.join(destDir, 'entry'));
42+
fs.symlinkSync(path.join(outsideDir, 'other.txt'), path.join(destDir, 'hop'));
43+
}
44+
45+
// destDir/entry -> linkedDir/other.txt, where destDir/linkedDir -> outsideDir
46+
function setupLinkedDir(destDir, outsideDir) {
47+
fs.mkdirSync(outsideDir, { recursive: true });
48+
fs.mkdirSync(destDir, { recursive: true });
49+
fs.symlinkSync(outsideDir, path.join(destDir, 'linkedDir'));
50+
fs.symlinkSync(path.join('linkedDir', 'other.txt'), path.join(destDir, 'entry'));
51+
}
52+
53+
describe('a chain whose first hop stays inside destDir', () => {
54+
it('should not write past the end of the chain', async () => {
55+
const destDir = path.join(tempDir, 'dest');
56+
const outsideDir = path.join(tempDir, 'outside');
57+
setupChain(destDir, outsideDir);
58+
59+
const tarBuffer = await createTarBuffer([
60+
{ name: 'entry', type: 'file', content: 'content' },
61+
]);
62+
63+
await compressing.tar.uncompress(tarBuffer, destDir);
64+
65+
assert.strictEqual(
66+
fs.existsSync(path.join(outsideDir, 'other.txt')),
67+
false,
68+
'The entry should not be written at the end of the chain'
69+
);
70+
});
71+
72+
it('should handle a chain longer than two hops', async () => {
73+
const destDir = path.join(tempDir, 'dest');
74+
const outsideDir = path.join(tempDir, 'outside');
75+
fs.mkdirSync(outsideDir, { recursive: true });
76+
fs.mkdirSync(destDir, { recursive: true });
77+
fs.symlinkSync(path.join(destDir, 'hop1'), path.join(destDir, 'entry'));
78+
fs.symlinkSync(path.join(destDir, 'hop2'), path.join(destDir, 'hop1'));
79+
fs.symlinkSync(path.join(outsideDir, 'other.txt'), path.join(destDir, 'hop2'));
80+
81+
const tarBuffer = await createTarBuffer([
82+
{ name: 'entry', type: 'file', content: 'content' },
83+
]);
84+
85+
await compressing.tar.uncompress(tarBuffer, destDir);
86+
87+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'other.txt')), false);
88+
});
89+
90+
it('should behave the same in tgz extraction', async () => {
91+
const destDir = path.join(tempDir, 'dest');
92+
const outsideDir = path.join(tempDir, 'outside');
93+
setupChain(destDir, outsideDir);
94+
95+
const tarBuffer = await createTarBuffer([
96+
{ name: 'entry', type: 'file', content: 'content' },
97+
]);
98+
await compressing.tgz.uncompress(await gzipBuffer(tarBuffer), destDir);
99+
100+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'other.txt')), false);
101+
});
102+
103+
it('should behave the same in zip extraction', async () => {
104+
const destDir = path.join(tempDir, 'dest');
105+
const outsideDir = path.join(tempDir, 'outside');
106+
setupChain(destDir, outsideDir);
107+
108+
const zipBuffer = await createZipBuffer([
109+
{ name: 'entry', content: 'content' },
110+
]);
111+
await compressing.zip.uncompress(zipBuffer, destDir);
112+
113+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'other.txt')), false);
114+
});
115+
});
116+
117+
describe('a chain passing through a linked directory', () => {
118+
it('should resolve the directory component of the link target', async () => {
119+
const destDir = path.join(tempDir, 'dest');
120+
const outsideDir = path.join(tempDir, 'outside');
121+
setupLinkedDir(destDir, outsideDir);
122+
123+
const tarBuffer = await createTarBuffer([
124+
{ name: 'entry', type: 'file', content: 'content' },
125+
]);
126+
127+
await compressing.tar.uncompress(tarBuffer, destDir);
128+
129+
assert.strictEqual(
130+
fs.existsSync(path.join(outsideDir, 'other.txt')),
131+
false,
132+
'The linked directory in the target should be resolved, not taken literally'
133+
);
134+
});
135+
136+
it('should behave the same in tgz extraction', async () => {
137+
const destDir = path.join(tempDir, 'dest');
138+
const outsideDir = path.join(tempDir, 'outside');
139+
setupLinkedDir(destDir, outsideDir);
140+
141+
const tarBuffer = await createTarBuffer([
142+
{ name: 'entry', type: 'file', content: 'content' },
143+
]);
144+
await compressing.tgz.uncompress(await gzipBuffer(tarBuffer), destDir);
145+
146+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'other.txt')), false);
147+
});
148+
149+
it('should behave the same in zip extraction', async () => {
150+
const destDir = path.join(tempDir, 'dest');
151+
const outsideDir = path.join(tempDir, 'outside');
152+
setupLinkedDir(destDir, outsideDir);
153+
154+
const zipBuffer = await createZipBuffer([
155+
{ name: 'entry', content: 'content' },
156+
]);
157+
await compressing.zip.uncompress(zipBuffer, destDir);
158+
159+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'other.txt')), false);
160+
});
161+
});
162+
163+
describe('a symlink at the entry destination', () => {
164+
it('should be replaced by the entry instead of written through', async () => {
165+
const destDir = path.join(tempDir, 'dest');
166+
fs.mkdirSync(destDir, { recursive: true });
167+
fs.symlinkSync(path.join(destDir, 'hop'), path.join(destDir, 'entry'));
168+
fs.symlinkSync(path.join(destDir, 'final.txt'), path.join(destDir, 'hop'));
169+
170+
const tarBuffer = await createTarBuffer([
171+
{ name: 'entry', type: 'file', content: 'content' },
172+
]);
173+
174+
await compressing.tar.uncompress(tarBuffer, destDir);
175+
176+
assert.strictEqual(
177+
fs.lstatSync(path.join(destDir, 'entry')).isSymbolicLink(),
178+
false,
179+
'The symlink at the destination should have been replaced by a regular file'
180+
);
181+
assert.strictEqual(fs.readFileSync(path.join(destDir, 'entry'), 'utf8'), 'content');
182+
assert.strictEqual(
183+
fs.existsSync(path.join(destDir, 'final.txt')),
184+
false,
185+
'The chain should not have been followed to its target'
186+
);
187+
});
188+
189+
it('should leave the file the symlink points at untouched', async () => {
190+
const destDir = path.join(tempDir, 'dest');
191+
fs.mkdirSync(destDir, { recursive: true });
192+
const target = path.join(destDir, 'target.txt');
193+
fs.writeFileSync(target, 'ORIGINAL_CONTENT');
194+
fs.symlinkSync(target, path.join(destDir, 'entry'));
195+
196+
const tarBuffer = await createTarBuffer([
197+
{ name: 'entry', type: 'file', content: 'new content' },
198+
]);
199+
200+
await compressing.tar.uncompress(tarBuffer, destDir);
201+
202+
assert.strictEqual(
203+
fs.readFileSync(target, 'utf8'),
204+
'ORIGINAL_CONTENT',
205+
'Writing an entry must not reach through a symlink to its target'
206+
);
207+
assert.strictEqual(fs.readFileSync(path.join(destDir, 'entry'), 'utf8'), 'new content');
208+
});
209+
});
210+
211+
describe('linked directories inside destDir', () => {
212+
it('should still be traversed when writing an entry beneath them', async () => {
213+
const destDir = path.join(tempDir, 'dest');
214+
const realDir = path.join(destDir, 'real');
215+
fs.mkdirSync(realDir, { recursive: true });
216+
fs.symlinkSync(realDir, path.join(destDir, 'linkDir'));
217+
218+
const tarBuffer = await createTarBuffer([
219+
{ name: 'linkDir/final.txt', type: 'file', content: 'content' },
220+
]);
221+
222+
await compressing.tar.uncompress(tarBuffer, destDir);
223+
224+
assert.strictEqual(
225+
fs.readFileSync(path.join(realDir, 'final.txt'), 'utf8'),
226+
'content',
227+
'A linked directory inside destDir should still be traversed'
228+
);
229+
});
230+
});
231+
232+
describe('an extraction directory reached through a symlink', () => {
233+
// destDir is given as linkBase/dest while its real path is realBase/dest, the
234+
// shape /var -> /private/var produces on macOS. A link target written in the
235+
// real namespace must still be recognised as living inside destDir.
236+
//
237+
// Skipped on Windows, where a dangling link resolves differently and the entry
238+
// is skipped regardless. That behaviour predates this change, and the namespace
239+
// divergence covered here is a POSIX shape.
240+
const itPosix = process.platform === 'win32' ? it.skip : it;
241+
242+
itPosix('should accept a dangling target written in the real namespace', async () => {
243+
const realBase = path.join(tempDir, 'realBase');
244+
const linkBase = path.join(tempDir, 'linkBase');
245+
fs.mkdirSync(path.join(realBase, 'dest'), { recursive: true });
246+
fs.symlinkSync(realBase, linkBase);
247+
248+
const destDir = path.join(linkBase, 'dest');
249+
// realpathSync, not the realBase path: tempDir may itself sit behind a symlink.
250+
const realDest = fs.realpathSync(path.join(realBase, 'dest'));
251+
fs.symlinkSync(path.join(realDest, 'final.txt'), path.join(destDir, 'entry'));
252+
253+
const tarBuffer = await createTarBuffer([
254+
{ name: 'entry', type: 'file', content: 'content' },
255+
]);
256+
257+
await compressing.tar.uncompress(tarBuffer, destDir);
258+
259+
assert.strictEqual(
260+
fs.readFileSync(path.join(destDir, 'entry'), 'utf8'),
261+
'content',
262+
'A target inside destDir should be accepted whichever namespace names it'
263+
);
264+
});
265+
});
266+
267+
describe('symlink cycles', () => {
268+
it('should terminate rather than loop', async () => {
269+
const destDir = path.join(tempDir, 'dest');
270+
fs.mkdirSync(destDir, { recursive: true });
271+
fs.symlinkSync(path.join(destDir, 'b'), path.join(destDir, 'entry'));
272+
fs.symlinkSync(path.join(destDir, 'entry'), path.join(destDir, 'b'));
273+
274+
const tarBuffer = await createTarBuffer([
275+
{ name: 'entry', type: 'file', content: 'content' },
276+
]);
277+
278+
await compressing.tar.uncompress(tarBuffer, destDir);
279+
280+
assert.strictEqual(fs.lstatSync(path.join(destDir, 'entry')).isSymbolicLink(), true);
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)