diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index 085485ca8a1a97..033bf30558758a 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -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); } @@ -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; } @@ -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; } } @@ -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); } @@ -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); } @@ -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) || diff --git a/test/parallel/test-vfs-real-provider-symlinks.js b/test/parallel/test-vfs-real-provider-symlinks.js index d84c17ecd8490d..9cc04badfeb0e6 100644 --- a/test/parallel/test-vfs-real-provider-symlinks.js +++ b/test/parallel/test-vfs-real-provider-symlinks.js @@ -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) -> /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 });