Skip to content

Commit f1acbb7

Browse files
committed
vfs: prevent RealFSProvider sandbox escape via symlinks
The containment check in RealFSProvider#resolvePath was thrown inside the try block wrapping fs.realpathSync(). Because the rejection carried an ENOENT code, the `if (err?.code !== 'ENOENT') throw err` catch mistook it for "path does not exist yet" and returned the raw, symlink-containing path, which the caller then opened -- following the symlink out of the root. #verifyAncestorInRoot had the same self-swallowing flaw, making it a no-op. As a result readFile/stat/open/write on a symlink pointing outside the root read and wrote real files outside the sandbox. Move the containment checks outside the try/catch so a rejection can no longer be swallowed by the ENOENT handler, and compare against a canonicalized root (#realRoot) so the checks are sound even when the root path itself contains symlinked components (e.g. /tmp -> /private/tmp). Add regression tests covering read/stat/open/write and new-file creation through symlinks that resolve outside the provider root.
1 parent 116302d commit f1acbb7

2 files changed

Lines changed: 104 additions & 31 deletions

File tree

lib/internal/vfs/providers/real.js

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -238,14 +238,27 @@ class RealFileHandle extends VirtualFileHandle {
238238
*/
239239
class RealFSProvider extends VirtualProvider {
240240
#rootPath;
241+
#realRoot;
241242

242243
/**
243244
* @param {string} rootPath The real filesystem path to use as root
244245
*/
245246
constructor(rootPath) {
246247
super();
247-
// Resolve to absolute path and normalize
248+
// Resolve to absolute path and normalize. This is the user-facing root
249+
// exposed via `rootPath` and used to translate symlink targets.
248250
this.#rootPath = path.resolve(getValidatedPath(rootPath, 'rootPath'));
251+
// Canonicalize the root (resolving any symlinked path components) for use
252+
// in containment checks. `fs.realpathSync()` on a candidate returns a
253+
// fully-resolved path, so comparing it against a non-canonical root can
254+
// both reject legitimate in-root paths and (together with the symlink
255+
// resolution below) fail to reject escapes. Fall back to the resolved
256+
// path when the root does not exist on disk yet.
257+
try {
258+
this.#realRoot = fs.realpathSync(this.#rootPath);
259+
} catch {
260+
this.#realRoot = this.#rootPath;
261+
}
249262
setOwnProperty(this, 'readonly', false);
250263
setOwnProperty(this, 'supportsSymlinks', true);
251264
}
@@ -272,36 +285,45 @@ class RealFSProvider extends VirtualProvider {
272285
normalized = normalized.slice(1);
273286
}
274287

275-
// Join with root and resolve
276-
const realPath = path.resolve(this.#rootPath, normalized);
288+
// Join with the canonical root and resolve `.`/`..` lexically.
289+
const realPath = path.resolve(this.#realRoot, normalized);
277290

278-
// Security check: ensure the resolved path is within rootPath
279-
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
280-
this.#rootPath :
281-
this.#rootPath + path.sep;
291+
// Security check: ensure the lexically-resolved path is within the root.
292+
// Catches `..` traversal and sibling-prefix paths before touching disk.
293+
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
294+
this.#realRoot :
295+
this.#realRoot + path.sep;
282296

283-
if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) {
297+
if (realPath !== this.#realRoot && !StringPrototypeStartsWith(realPath, rootWithSep)) {
284298
throw createENOENT('open', vfsPath);
285299
}
286300

287-
// Resolve symlinks to prevent escape via symbolic links
301+
// Resolve symlinks to prevent escape via symbolic links.
288302
if (followSymlinks) {
303+
let resolved;
289304
try {
290-
const resolved = fs.realpathSync(realPath);
291-
if (resolved !== this.#rootPath &&
292-
!StringPrototypeStartsWith(resolved, rootWithSep)) {
293-
throw createENOENT('open', vfsPath);
294-
}
295-
return resolved;
305+
resolved = fs.realpathSync(realPath);
296306
} catch (err) {
307+
// Only a genuine "does not exist" is handled here. Any other error
308+
// (including the containment rejection below) must propagate.
297309
if (err?.code !== 'ENOENT') throw err;
298-
// Path doesn't exist yet - verify deepest existing ancestor
310+
// Path doesn't exist yet - verify the deepest existing ancestor is
311+
// within the root, then return the in-root path for creation.
299312
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
300313
return realPath;
301314
}
315+
// IMPORTANT: perform the containment check OUTSIDE the try/catch above.
316+
// Throwing it inside the try would let the `err?.code !== 'ENOENT'`
317+
// catch swallow the rejection (createEACCES/ENOENT share codes with real
318+
// fs errors), silently returning a path that escapes the root.
319+
if (resolved !== this.#realRoot &&
320+
!StringPrototypeStartsWith(resolved, rootWithSep)) {
321+
throw createEACCES('open', vfsPath);
322+
}
323+
return resolved;
302324
}
303325

304-
// For lstat/readlink (no final symlink follow), check parent only
326+
// For lstat/readlink (no final symlink follow), check ancestors only.
305327
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
306328
return realPath;
307329
}
@@ -314,18 +336,23 @@ class RealFSProvider extends VirtualProvider {
314336
*/
315337
#verifyAncestorInRoot(realPath, rootWithSep, vfsPath) {
316338
let current = path.dirname(realPath);
317-
while (current.length >= this.#rootPath.length) {
339+
while (current.length >= this.#realRoot.length) {
340+
let resolved;
318341
try {
319-
const resolved = fs.realpathSync(current);
320-
if (resolved !== this.#rootPath &&
321-
!StringPrototypeStartsWith(resolved, rootWithSep)) {
322-
throw createENOENT('open', vfsPath);
323-
}
324-
return;
342+
resolved = fs.realpathSync(current);
325343
} catch (err) {
344+
// A non-existent ancestor: keep walking up. Any other error propagates.
326345
if (err?.code !== 'ENOENT') throw err;
327346
current = path.dirname(current);
347+
continue;
348+
}
349+
// Deepest existing ancestor found. Enforce containment OUTSIDE the
350+
// try/catch so the rejection is not swallowed by the ENOENT handler.
351+
if (resolved !== this.#realRoot &&
352+
!StringPrototypeStartsWith(resolved, rootWithSep)) {
353+
throw createEACCES('open', vfsPath);
328354
}
355+
return;
329356
}
330357
}
331358

@@ -456,9 +483,9 @@ class RealFSProvider extends VirtualProvider {
456483
}
457484
const realPath = this.#resolvePath(vfsPath);
458485
const resolvedTarget = path.resolve(path.dirname(realPath), target);
459-
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
460-
this.#rootPath : this.#rootPath + path.sep;
461-
if (resolvedTarget !== this.#rootPath &&
486+
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
487+
this.#realRoot : this.#realRoot + path.sep;
488+
if (resolvedTarget !== this.#realRoot &&
462489
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
463490
throw createEACCES('symlink', vfsPath);
464491
}
@@ -472,9 +499,9 @@ class RealFSProvider extends VirtualProvider {
472499
}
473500
const realPath = this.#resolvePath(vfsPath);
474501
const resolvedTarget = path.resolve(path.dirname(realPath), target);
475-
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
476-
this.#rootPath : this.#rootPath + path.sep;
477-
if (resolvedTarget !== this.#rootPath &&
502+
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
503+
this.#realRoot : this.#realRoot + path.sep;
504+
if (resolvedTarget !== this.#realRoot &&
478505
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
479506
throw createEACCES('symlink', vfsPath);
480507
}
@@ -485,7 +512,7 @@ class RealFSProvider extends VirtualProvider {
485512
// because fs.realpathSync (a JS impl) preserves case but fs.promises.realpath
486513
// (native) canonicalizes the drive letter and other components.
487514
#resolvedToVfsPath(resolved, vfsPath, syscall) {
488-
const rel = path.relative(this.#rootPath, resolved);
515+
const rel = path.relative(this.#realRoot, resolved);
489516
if (rel === '') return '/';
490517
if (rel === '..' ||
491518
StringPrototypeStartsWith(rel, '..' + path.sep) ||

test/parallel/test-vfs-real-provider-symlinks.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,52 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root));
9494
{ code: 'EACCES' });
9595
}
9696

97+
// Regression test (sandbox escape via symlink): read/stat/open/write through
98+
// a symlink whose target is OUTSIDE the root must be rejected, never silently
99+
// followed. Previously the containment rejection was thrown inside the
100+
// `try { fs.realpathSync() }` block in RealFSProvider.#resolvePath and, since
101+
// it carried an ENOENT code, was swallowed by the "path doesn't exist yet"
102+
// catch -- so the raw (symlink-containing) path was returned and the caller's
103+
// real fs op followed it out of the root.
104+
{
105+
// esc-link (created above) -> <tmpdir>/outside.txt, which contains
106+
// 'forbidden' and lives outside the provider root.
107+
assert.throws(() => myVfs.readFileSync('/esc-link'), { code: 'EACCES' });
108+
assert.throws(() => myVfs.statSync('/esc-link'), { code: 'EACCES' });
109+
assert.throws(() => myVfs.openSync('/esc-link', 'r'), { code: 'EACCES' });
110+
await assert.rejects(myVfs.promises.readFile('/esc-link'),
111+
{ code: 'EACCES' });
112+
await assert.rejects(myVfs.promises.open('/esc-link', 'r'),
113+
{ code: 'EACCES' });
114+
115+
// A write through the symlink must not touch the out-of-root file.
116+
assert.throws(() => myVfs.writeFileSync('/esc-link', 'pwned'),
117+
{ code: 'EACCES' });
118+
assert.strictEqual(
119+
fs.readFileSync(path.join(tmpdir.path, 'outside.txt'), 'utf8'),
120+
'forbidden');
121+
}
122+
123+
// Regression test: creating a NEW file underneath a directory symlink that
124+
// points outside the root must be rejected (the ENOENT "create" branch,
125+
// which relied on #verifyAncestorInRoot -- previously a no-op because it
126+
// swallowed its own containment rejection).
127+
{
128+
const outsideDir = path.join(tmpdir.path, 'outside-dir');
129+
fs.mkdirSync(outsideDir, { recursive: true });
130+
fs.symlinkSync(outsideDir, path.join(root, 'esc-dir'));
131+
132+
assert.throws(() => myVfs.writeFileSync('/esc-dir/pwned.txt', 'x'),
133+
{ code: 'EACCES' });
134+
assert.throws(() => myVfs.openSync('/esc-dir/pwned.txt', 'w'),
135+
{ code: 'EACCES' });
136+
await assert.rejects(myVfs.promises.writeFile('/esc-dir/pwned.txt', 'x'),
137+
{ code: 'EACCES' });
138+
139+
// Nothing must have been created outside the root.
140+
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'pwned.txt')), false);
141+
}
142+
97143
// Realpath on root and on a subdir
98144
{
99145
fs.mkdirSync(path.join(root, 'sub2'), { recursive: true });

0 commit comments

Comments
 (0)