From b90e6fc727b4c18074a6bfb4fe9b3ed1a3648e5a Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Tue, 4 Aug 2026 12:02:22 -0300 Subject: [PATCH] fs: do not descend into symlinks for ** unless following symlinks Signed-off-by: RafaelGSS --- lib/internal/fs/glob.js | 8 +- ...est-fs-glob-no-follow-symlink-globstar.mjs | 81 +++++++++++++++++++ test/parallel/test-fs-glob.mjs | 17 +--- 3 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 test/parallel/test-fs-glob-no-follow-symlink-globstar.mjs diff --git a/lib/internal/fs/glob.js b/lib/internal/fs/glob.js index c608016833f9..ad247898c46b 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -619,9 +619,9 @@ class Glob { subPatterns.add(index + 2); } if ((nextMatches || pattern.at(0) === '.') && - (entryIsDirectory || entry.isSymbolicLink()) && !fromSymlink) { + entryIsDirectory && !fromSymlink) { // If pattern after ** matches, or pattern starts with "." - // and entry is a directory or symlink, add to potential patterns + // and entry is a directory, add to potential patterns subPatterns.add(nextIndex); } @@ -840,9 +840,9 @@ class Glob { subPatterns.add(index + 2); } if ((nextMatches || pattern.at(0) === '.') && - (entryIsDirectory || entry.isSymbolicLink()) && !fromSymlink) { + entryIsDirectory && !fromSymlink) { // If pattern after ** matches, or pattern starts with "." - // and entry is a directory or symlink, add to potential patterns + // and entry is a directory, add to potential patterns subPatterns.add(nextIndex); } diff --git a/test/parallel/test-fs-glob-no-follow-symlink-globstar.mjs b/test/parallel/test-fs-glob-no-follow-symlink-globstar.mjs new file mode 100644 index 000000000000..b1b3cda8d016 --- /dev/null +++ b/test/parallel/test-fs-glob-no-follow-symlink-globstar.mjs @@ -0,0 +1,81 @@ +import * as common from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import assert from 'node:assert'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { glob as globPromise, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { globSync, glob as asyncGlob } from 'node:fs'; + +if (common.isWindows) { + // Directory symlinks require elevated privileges on Windows. + common.skip('symlinks are unreliable on Windows'); +} + +tmpdir.refresh(); + +// Layout: +// root/visible.txt +// root/link -> outside (outside/ holds files that must NOT be listed) +const base = tmpdir.resolve('globstar-symlink'); +const root = path.resolve(base, 'workspace'); +const outside = path.resolve(base, 'outside'); +const outsideNested = path.resolve(outside, 'nested'); + +await mkdir(root, { recursive: true }); +await mkdir(outsideNested, { recursive: true }); +await writeFile(path.resolve(root, 'visible.txt'), 'visible'); +await writeFile(path.resolve(outside, 'secret-one.txt'), 'secret one'); +await writeFile(path.resolve(outsideNested, 'secret-two.txt'), 'secret two'); +await symlink(outside, path.resolve(root, 'link'), 'dir'); + +const expected = ['link', 'visible.txt'].map((e) => e.replaceAll('/', path.sep)).sort(); + +// `**/*` must not descend into the symlinked directory, neither by default nor +// with followSymlinks explicitly disabled. +assert.deepStrictEqual(globSync('**/*', { cwd: root }).sort(), expected); +assert.deepStrictEqual( + globSync('**/*', { cwd: root, followSymlinks: false }).sort(), + expected, +); + +const promiseMatches = []; +for await (const entry of globPromise('**/*', { cwd: root, followSymlinks: false })) { + promiseMatches.push(entry); +} +assert.deepStrictEqual(promiseMatches.sort(), expected); + +const callbackMatches = await new Promise((resolve, reject) => { + asyncGlob('**/*', { cwd: root, followSymlinks: false }, (err, matches) => { + if (err) reject(err); + else resolve(matches); + }); +}); +assert.deepStrictEqual(callbackMatches.sort(), expected); + +// Under the permission model, `**/*` must not expose entries from a symlink +// target that is outside the granted read set. +if (common.hasCrypto) { + const child = ` + const assert = require('node:assert'); + const fs = require('node:fs'); + const path = require('node:path'); + const root = ${JSON.stringify(root)}; + const outside = ${JSON.stringify(outside)}; + const expected = ['link', 'visible.txt'].map((e) => e.replaceAll('/', path.sep)).sort(); + assert.strictEqual(process.permission.has('fs.read', root), true); + assert.strictEqual(process.permission.has('fs.read', outside), false); + assert.throws(() => fs.readdirSync(outside), { code: 'ERR_ACCESS_DENIED' }); + assert.deepStrictEqual(fs.globSync('**/*', { cwd: root }).sort(), expected); + assert.deepStrictEqual( + fs.globSync('**/*', { cwd: root, followSymlinks: false }).sort(), + expected, + ); + `; + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--permission', + `--allow-fs-read=${root}`, + '-e', + child, + ], { encoding: 'utf8' }); + assert.strictEqual(status, 0, stderr || stdout); +} diff --git a/test/parallel/test-fs-glob.mjs b/test/parallel/test-fs-glob.mjs index bd95bce7d0e3..88a94f7a84ec 100644 --- a/test/parallel/test-fs-glob.mjs +++ b/test/parallel/test-fs-glob.mjs @@ -118,7 +118,7 @@ const patterns = { 'a/x', 'a/z', ], - './**/a': common.isWindows ? ['a'] : ['a', 'a/symlink/a', 'a/symlink/a/b/c/a'], + './**/a': common.isWindows ? ['a'] : ['a', 'a/symlink/a'], './**/a/**/': [ 'a', 'a/abcdef', @@ -138,10 +138,6 @@ const patterns = { 'a/symlink', 'a/symlink/a', 'a/symlink/a/b', - 'a/symlink/a/b/c', - 'a/symlink/a/b/c/a', - 'a/symlink/a/b/c/a/b', - 'a/symlink/a/b/c/a/b/c', ]), 'a/x', 'a/z', @@ -172,9 +168,6 @@ const patterns = { 'a/symlink/a', 'a/symlink/a/b', 'a/symlink/a/b/c', - 'a/symlink/a/b/c/a', - 'a/symlink/a/b/c/a/b', - 'a/symlink/a/b/c/a/b/c', ]), 'a/x', 'a/z', @@ -182,13 +175,6 @@ const patterns = { './**/a/**/a/**/': common.isWindows ? [] : [ 'a/symlink/a', 'a/symlink/a/b', - 'a/symlink/a/b/c', - 'a/symlink/a/b/c/a', - 'a/symlink/a/b/c/a/b', - 'a/symlink/a/b/c/a/b/c', - 'a/symlink/a/b/c/a/b/c/a', - 'a/symlink/a/b/c/a/b/c/a/b', - 'a/symlink/a/b/c/a/b/c/a/b/c', ], '+(a|b|c)/a{/,bc*}/**': [ 'a/abcdef', @@ -267,7 +253,6 @@ const patterns = { 'a/symlink/a/**/*': common.isWindows ? [] : [ 'a/symlink/a/b', 'a/symlink/a/b/c', - 'a/symlink/a/b/c/a', ], 'a/!(symlink)/**/..': [ 'a',