From 62229c0ba85f46a8316216ec4982d9e8367a82ed Mon Sep 17 00:00:00 2001 From: AkshatOP Date: Fri, 26 Jun 2026 17:54:58 +0530 Subject: [PATCH] vfs: prevent stack overflow in recursive readdir on circular symlinks MemoryProvider#readdirSync with `recursive: true` follows symlinks to directories during traversal but did not bound the number of symlinks followed along a branch. A circular symlink therefore caused unbounded recursion in the internal walk() helper until the call stack was exhausted, crashing the process with `RangeError: Maximum call stack size exceeded`. Both the synchronous and promise-based variants were affected. The existing kMaxSymlinkDepth guard in #lookupEntry did not help, because walk() resolved each symlink target with a fresh depth of zero. Track the number of symlink hops along the current branch and stop recursing once it would exceed kMaxSymlinkDepth, mirroring the ELOOP guard in #lookupEntry and the behavior of the real filesystem, which follows directory symlinks until the OS symlink limit is reached. The entries themselves are still listed, so non-circular symlinks continue to be followed as before. Fixes: https://github.com/nodejs/node/issues/64148 Signed-off-by: AkshatOP --- lib/internal/vfs/providers/memory.js | 12 ++-- .../test-vfs-readdir-symlink-recursive.js | 56 ++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 5fc18ccdd2b517..06d2a6e632137c 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -612,7 +612,7 @@ class MemoryProvider extends VirtualProvider { #readdirRecursive(dirEntry, dirPath, withFileTypes) { const results = []; - const walk = (entry, currentPath, relativePath) => { + const walk = (entry, currentPath, relativePath, symlinkDepth) => { this.#ensurePopulated(entry, currentPath); for (const { 0: name, 1: childEntry } of entry.children) { @@ -636,6 +636,7 @@ class MemoryProvider extends VirtualProvider { // Follow symlinks to directories for recursive traversal let resolvedChild = childEntry; + let childSymlinkDepth = symlinkDepth; if (childEntry.isSymbolicLink()) { const targetPath = this.#resolveSymlinkTarget( pathPosix.join(currentPath, name), childEntry.target, @@ -644,15 +645,18 @@ class MemoryProvider extends VirtualProvider { if (result.entry) { resolvedChild = result.entry; } + // Bound symlink hops to avoid unbounded recursion on cycles. + childSymlinkDepth = symlinkDepth + 1; } - if (resolvedChild.isDirectory()) { + if (resolvedChild.isDirectory() && + childSymlinkDepth <= kMaxSymlinkDepth) { const childPath = pathPosix.join(currentPath, name); - walk(resolvedChild, childPath, childRelative); + walk(resolvedChild, childPath, childRelative, childSymlinkDepth); } } }; - walk(dirEntry, dirPath, ''); + walk(dirEntry, dirPath, '', 0); return results; } diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js index 1f9661947c7444..dba6cf512846a7 100644 --- a/test/parallel/test-vfs-readdir-symlink-recursive.js +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -3,7 +3,7 @@ // Recursive readdir must follow symlinks to directories. -require('../common'); +const common = require('../common'); const assert = require('assert'); const vfs = require('node:vfs'); @@ -52,3 +52,57 @@ assert.ok( assert.ok(dirents.some((d) => d.name === 'sub' && d.isDirectory())); assert.ok(dirents.some((d) => d.name === 'lnk' && d.isSymbolicLink())); } + +// Recursive readdir on circular symlinks must terminate, not overflow the +// stack. Regression test for https://github.com/nodejs/node/issues/64148 + +// Self-referential symlink: /dir/loop -> /dir +{ + const v = vfs.create(); + v.mkdirSync('/dir'); + v.symlinkSync('/dir', '/dir/loop'); + + const entries = v.readdirSync('/', { recursive: true }); + // Terminates, follows the symlink at least one level, stays bounded. + assert.ok(entries.includes('dir')); + assert.ok(entries.includes('dir/loop')); + assert.ok(entries.includes('dir/loop/loop')); + assert.ok(entries.length < 100, `unbounded result: ${entries.length}`); +} + +// Mutual circular chain: /a/link_to_b -> /b and /b/link_to_a -> /a +{ + const v = vfs.create(); + v.mkdirSync('/a'); + v.mkdirSync('/b'); + v.symlinkSync('/a', '/b/link_to_a'); + v.symlinkSync('/b', '/a/link_to_b'); + + const entries = v.readdirSync('/', { recursive: true }); + assert.ok(entries.includes('a')); + assert.ok(entries.includes('b')); + assert.ok(entries.length < 200, `unbounded result: ${entries.length}`); +} + +// withFileTypes:true on a circular symlink must also terminate. +{ + const v = vfs.create(); + v.mkdirSync('/dir'); + v.symlinkSync('/dir', '/dir/loop'); + + const dirents = v.readdirSync('/', { withFileTypes: true, recursive: true }); + assert.ok(dirents.some((d) => d.name === 'loop' && d.isSymbolicLink())); + assert.ok(dirents.length < 100, `unbounded result: ${dirents.length}`); +} + +// Async promises.readdir variant must reject-free terminate as well. +{ + const v = vfs.create(); + v.mkdirSync('/dir'); + v.symlinkSync('/dir', '/dir/loop'); + + v.promises.readdir('/', { recursive: true }).then(common.mustCall((entries) => { + assert.ok(entries.includes('dir/loop')); + assert.ok(entries.length < 100, `unbounded result: ${entries.length}`); + })); +}