Skip to content

Commit 0bad0b4

Browse files
fs: enhance glob methods to handle symlink cycles with followSymlinks option
1 parent cab7a9f commit 0bad0b4

File tree

2 files changed

+86
-7
lines changed

2 files changed

+86
-7
lines changed

lib/internal/fs/glob.js

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const {
1616
StringPrototypeEndsWith,
1717
} = primordials;
1818

19-
const { lstatSync, readdirSync, statSync } = require('fs');
20-
const { lstat, readdir, stat } = require('fs/promises');
19+
const { lstatSync, readdirSync, statSync, realpathSync } = require('fs');
20+
const { lstat, readdir, stat, realpath } = require('fs/promises');
2121
const { join, resolve, basename, isAbsolute, dirname } = require('path');
2222

2323
const {
@@ -134,6 +134,7 @@ class Cache {
134134
#statsCache = new SafeMap();
135135
#readdirCache = new SafeMap();
136136
#followSymlinks = false;
137+
#visitedRealpaths = new SafeSet();
137138

138139
setFollowSymlinks(followSymlinks) {
139140
this.#followSymlinks = followSymlinks;
@@ -143,6 +144,14 @@ class Cache {
143144
return this.#followSymlinks;
144145
}
145146

147+
hasVisitedRealPath(path) {
148+
return this.#visitedRealpaths.has(path);
149+
}
150+
151+
addVisitedRealPath(path) {
152+
this.#visitedRealpaths.add(path);
153+
}
154+
146155
stat(path) {
147156
const cached = this.#statsCache.get(path);
148157
if (cached) {
@@ -457,18 +466,33 @@ class Glob {
457466
for (let i = 0; i < children.length; i++) {
458467
const entry = children[i];
459468
const entryPath = join(path, entry.name);
469+
const entryFullPath = join(fullpath, entry.name);
460470

461471
// If followSymlinks is enabled and entry is a symlink, resolve it
462472
let resolvedEntry = entry;
473+
let resolvedRealpath;
463474
if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) {
464-
const resolved = this.#cache.statSync(join(fullpath, entry.name));
475+
const resolved = this.#cache.statSync(entryFullPath);
465476
if (resolved && !resolved.isSymbolicLink()) {
466477
resolvedEntry = resolved;
467478
resolvedEntry.name = entry.name;
479+
try {
480+
resolvedRealpath = realpathSync(entryFullPath);
481+
} catch {
482+
// broken symlink or permission issue – fall back to lstat view
483+
}
484+
}
485+
}
486+
487+
// Guard against cycles when following symlinks into directories
488+
if (resolvedRealpath && resolvedEntry.isDirectory()) {
489+
if (this.#cache.hasVisitedRealPath(resolvedRealpath)) {
490+
continue;
468491
}
492+
this.#cache.addVisitedRealPath(resolvedRealpath);
469493
}
470494

471-
this.#cache.addToStatCache(join(fullpath, entry.name), resolvedEntry);
495+
this.#cache.addToStatCache(entryFullPath, resolvedEntry);
472496

473497
const subPatterns = new SafeSet();
474498
const nSymlinks = new SafeSet();
@@ -678,18 +702,33 @@ class Glob {
678702
for (let i = 0; i < children.length; i++) {
679703
const entry = children[i];
680704
const entryPath = join(path, entry.name);
705+
const entryFullPath = join(fullpath, entry.name);
681706

682707
// If followSymlinks is enabled and entry is a symlink, resolve it
683708
let resolvedEntry = entry;
709+
let resolvedRealpath;
684710
if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) {
685-
const resolved = await this.#cache.stat(join(fullpath, entry.name));
711+
const resolved = await this.#cache.stat(entryFullPath);
686712
if (resolved && !resolved.isSymbolicLink()) {
687713
resolvedEntry = resolved;
688714
resolvedEntry.name = entry.name;
715+
try {
716+
resolvedRealpath = await realpath(entryFullPath);
717+
} catch {
718+
// broken symlink or permission issue – fall back to lstat view
719+
}
720+
}
721+
}
722+
723+
// Guard against cycles when following symlinks into directories
724+
if (resolvedRealpath && resolvedEntry.isDirectory()) {
725+
if (this.#cache.hasVisitedRealPath(resolvedRealpath)) {
726+
continue;
689727
}
728+
this.#cache.addVisitedRealPath(resolvedRealpath);
690729
}
691730

692-
this.#cache.addToStatCache(join(fullpath, entry.name), resolvedEntry);
731+
this.#cache.addToStatCache(entryFullPath, resolvedEntry);
693732

694733
const subPatterns = new SafeSet();
695734
const nSymlinks = new SafeSet();

test/parallel/test-fs-glob-followsymlinks.mjs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as common from '../common/index.mjs';
22
import tmpdir from '../common/tmpdir.js';
33
import { resolve } from 'node:path';
44
import { mkdir, writeFile, symlink, glob as asyncGlob } from 'node:fs/promises';
5-
import { globSync } from 'node:fs';
5+
import { globSync, mkdirSync, symlinkSync } from 'node:fs';
66
import { test } from 'node:test';
77
import assert from 'node:assert';
88

@@ -204,3 +204,43 @@ test('glob with withFileTypes and followSymlinks', async () => {
204204
assert.ok(entry.isFile() || entry.isDirectory(), `Entry ${entry.name} should be a file or directory`);
205205
});
206206
});
207+
208+
test('glob with followSymlinks avoids cycles', async () => {
209+
if (common.isWindows) {
210+
return;
211+
}
212+
213+
// Create a symlink that points back up the tree to test cycle protection
214+
const loopDir = resolve(fixtureDir, 'loop');
215+
await mkdir(loopDir, { recursive: true });
216+
const loopTarget = resolve(loopDir, 'back');
217+
await symlink(fixtureDir, loopTarget, 'dir');
218+
219+
const results = [];
220+
for await (const file of asyncGlob('**/*.txt', { cwd: fixtureDir, followSymlinks: true })) {
221+
results.push(file);
222+
// Hard break guard: if we ever blow up beyond a sane bound, bail to avoid hanging tests
223+
assert.ok(results.length < 200, 'Traversal should not loop infinitely when following symlinks');
224+
}
225+
226+
// Ensure we still find original files
227+
assert.ok(results.includes('real-dir/file.txt'));
228+
assert.ok(results.includes('regular-dir/regular.txt'));
229+
});
230+
231+
test('globSync with followSymlinks avoids cycles', () => {
232+
if (common.isWindows) {
233+
return;
234+
}
235+
236+
const loopDir = resolve(fixtureDir, 'loop-sync');
237+
mkdirSync(loopDir, { recursive: true });
238+
const loopTarget = resolve(loopDir, 'back');
239+
symlinkSync(fixtureDir, loopTarget, 'dir');
240+
241+
const results = globSync('**/*.txt', { cwd: fixtureDir, followSymlinks: true });
242+
243+
assert.ok(results.includes('real-dir/file.txt'));
244+
assert.ok(results.includes('regular-dir/regular.txt'));
245+
assert.ok(results.length < 200, 'Traversal should not loop infinitely when following symlinks (sync)');
246+
});

0 commit comments

Comments
 (0)