@@ -238,14 +238,27 @@ class RealFileHandle extends VirtualFileHandle {
238238 */
239239class 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 ) ||
0 commit comments