Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 58 additions & 31 deletions lib/internal/vfs/providers/real.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,27 @@ class RealFileHandle extends VirtualFileHandle {
*/
class RealFSProvider extends VirtualProvider {
#rootPath;
#realRoot;

/**
* @param {string} rootPath The real filesystem path to use as root
*/
constructor(rootPath) {
super();
// Resolve to absolute path and normalize
// Resolve to absolute path and normalize. This is the user-facing root
// exposed via `rootPath` and used to translate symlink targets.
this.#rootPath = path.resolve(getValidatedPath(rootPath, 'rootPath'));
// Canonicalize the root (resolving any symlinked path components) for use
// in containment checks. `fs.realpathSync()` on a candidate returns a
// fully-resolved path, so comparing it against a non-canonical root can
// both reject legitimate in-root paths and (together with the symlink
// resolution below) fail to reject escapes. Fall back to the resolved
// path when the root does not exist on disk yet.
try {
this.#realRoot = fs.realpathSync(this.#rootPath);
} catch {
this.#realRoot = this.#rootPath;
}
setOwnProperty(this, 'readonly', false);
setOwnProperty(this, 'supportsSymlinks', true);
}
Expand All @@ -272,36 +285,45 @@ class RealFSProvider extends VirtualProvider {
normalized = normalized.slice(1);
}

// Join with root and resolve
const realPath = path.resolve(this.#rootPath, normalized);
// Join with the canonical root and resolve `.`/`..` lexically.
const realPath = path.resolve(this.#realRoot, normalized);

// Security check: ensure the resolved path is within rootPath
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
this.#rootPath :
this.#rootPath + path.sep;
// Security check: ensure the lexically-resolved path is within the root.
// Catches `..` traversal and sibling-prefix paths before touching disk.
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
this.#realRoot :
this.#realRoot + path.sep;

if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) {
if (realPath !== this.#realRoot && !StringPrototypeStartsWith(realPath, rootWithSep)) {
throw createENOENT('open', vfsPath);
}

// Resolve symlinks to prevent escape via symbolic links
// Resolve symlinks to prevent escape via symbolic links.
if (followSymlinks) {
let resolved;
try {
const resolved = fs.realpathSync(realPath);
if (resolved !== this.#rootPath &&
!StringPrototypeStartsWith(resolved, rootWithSep)) {
throw createENOENT('open', vfsPath);
}
return resolved;
resolved = fs.realpathSync(realPath);
} catch (err) {
// Only a genuine "does not exist" is handled here. Any other error
// (including the containment rejection below) must propagate.
if (err?.code !== 'ENOENT') throw err;
// Path doesn't exist yet - verify deepest existing ancestor
// Path doesn't exist yet - verify the deepest existing ancestor is
// within the root, then return the in-root path for creation.
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
return realPath;
}
// IMPORTANT: perform the containment check OUTSIDE the try/catch above.
// Throwing it inside the try would let the `err?.code !== 'ENOENT'`
// catch swallow the rejection (createEACCES/ENOENT share codes with real
// fs errors), silently returning a path that escapes the root.
if (resolved !== this.#realRoot &&
!StringPrototypeStartsWith(resolved, rootWithSep)) {
throw createEACCES('open', vfsPath);
}
return resolved;
}

// For lstat/readlink (no final symlink follow), check parent only
// For lstat/readlink (no final symlink follow), check ancestors only.
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
return realPath;
}
Expand All @@ -314,18 +336,23 @@ class RealFSProvider extends VirtualProvider {
*/
#verifyAncestorInRoot(realPath, rootWithSep, vfsPath) {
let current = path.dirname(realPath);
while (current.length >= this.#rootPath.length) {
while (current.length >= this.#realRoot.length) {
let resolved;
try {
const resolved = fs.realpathSync(current);
if (resolved !== this.#rootPath &&
!StringPrototypeStartsWith(resolved, rootWithSep)) {
throw createENOENT('open', vfsPath);
}
return;
resolved = fs.realpathSync(current);
} catch (err) {
// A non-existent ancestor: keep walking up. Any other error propagates.
if (err?.code !== 'ENOENT') throw err;
current = path.dirname(current);
continue;
}
// Deepest existing ancestor found. Enforce containment OUTSIDE the
// try/catch so the rejection is not swallowed by the ENOENT handler.
if (resolved !== this.#realRoot &&
!StringPrototypeStartsWith(resolved, rootWithSep)) {
throw createEACCES('open', vfsPath);
}
return;
}
}

Expand Down Expand Up @@ -456,9 +483,9 @@ class RealFSProvider extends VirtualProvider {
}
const realPath = this.#resolvePath(vfsPath);
const resolvedTarget = path.resolve(path.dirname(realPath), target);
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
this.#rootPath : this.#rootPath + path.sep;
if (resolvedTarget !== this.#rootPath &&
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
this.#realRoot : this.#realRoot + path.sep;
if (resolvedTarget !== this.#realRoot &&
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
throw createEACCES('symlink', vfsPath);
}
Expand All @@ -472,9 +499,9 @@ class RealFSProvider extends VirtualProvider {
}
const realPath = this.#resolvePath(vfsPath);
const resolvedTarget = path.resolve(path.dirname(realPath), target);
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
this.#rootPath : this.#rootPath + path.sep;
if (resolvedTarget !== this.#rootPath &&
const rootWithSep = this.#realRoot.endsWith(path.sep) ?
this.#realRoot : this.#realRoot + path.sep;
if (resolvedTarget !== this.#realRoot &&
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
throw createEACCES('symlink', vfsPath);
}
Expand All @@ -485,7 +512,7 @@ class RealFSProvider extends VirtualProvider {
// because fs.realpathSync (a JS impl) preserves case but fs.promises.realpath
// (native) canonicalizes the drive letter and other components.
#resolvedToVfsPath(resolved, vfsPath, syscall) {
const rel = path.relative(this.#rootPath, resolved);
const rel = path.relative(this.#realRoot, resolved);
if (rel === '') return '/';
if (rel === '..' ||
StringPrototypeStartsWith(rel, '..' + path.sep) ||
Expand Down
46 changes: 46 additions & 0 deletions test/parallel/test-vfs-real-provider-symlinks.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,52 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root));
{ code: 'EACCES' });
}

// Regression test (sandbox escape via symlink): read/stat/open/write through
// a symlink whose target is OUTSIDE the root must be rejected, never silently
// followed. Previously the containment rejection was thrown inside the
// `try { fs.realpathSync() }` block in RealFSProvider.#resolvePath and, since
// it carried an ENOENT code, was swallowed by the "path doesn't exist yet"
// catch -- so the raw (symlink-containing) path was returned and the caller's
// real fs op followed it out of the root.
{
// esc-link (created above) -> <tmpdir>/outside.txt, which contains
// 'forbidden' and lives outside the provider root.
assert.throws(() => myVfs.readFileSync('/esc-link'), { code: 'EACCES' });
assert.throws(() => myVfs.statSync('/esc-link'), { code: 'EACCES' });
assert.throws(() => myVfs.openSync('/esc-link', 'r'), { code: 'EACCES' });
await assert.rejects(myVfs.promises.readFile('/esc-link'),
{ code: 'EACCES' });
await assert.rejects(myVfs.promises.open('/esc-link', 'r'),
{ code: 'EACCES' });

// A write through the symlink must not touch the out-of-root file.
assert.throws(() => myVfs.writeFileSync('/esc-link', 'pwned'),
{ code: 'EACCES' });
assert.strictEqual(
fs.readFileSync(path.join(tmpdir.path, 'outside.txt'), 'utf8'),
'forbidden');
}

// Regression test: creating a NEW file underneath a directory symlink that
// points outside the root must be rejected (the ENOENT "create" branch,
// which relied on #verifyAncestorInRoot -- previously a no-op because it
// swallowed its own containment rejection).
{
const outsideDir = path.join(tmpdir.path, 'outside-dir');
fs.mkdirSync(outsideDir, { recursive: true });
fs.symlinkSync(outsideDir, path.join(root, 'esc-dir'));

assert.throws(() => myVfs.writeFileSync('/esc-dir/pwned.txt', 'x'),
{ code: 'EACCES' });
assert.throws(() => myVfs.openSync('/esc-dir/pwned.txt', 'w'),
{ code: 'EACCES' });
await assert.rejects(myVfs.promises.writeFile('/esc-dir/pwned.txt', 'x'),
{ code: 'EACCES' });

// Nothing must have been created outside the root.
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'pwned.txt')), false);
}

// Realpath on root and on a subdir
{
fs.mkdirSync(path.join(root, 'sub2'), { recursive: true });
Expand Down
Loading