Skip to content

Commit cc24413

Browse files
committed
permission: check permission model in realpathSync
The equivalent entry points (fs.realpathSync.native(), fs.promises.realpath(), fs.lstatSync(), fs.existsSync()) already deny access for the same paths Signed-off-by: RafaelGSS <rafael.nunu@hotmail.com> PR-URL: nodejs-private/node-private#973 Refs: https://hackerone.com/reports/3838675 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> CVE-ID: CVE-2026-106073
1 parent c273a40 commit cc24413

5 files changed

Lines changed: 96 additions & 3 deletions

File tree

‎lib/fs.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3369,6 +3369,17 @@ function realpathSync(p, options) {
33693369
validatePath(p);
33703370
p = pathModule.resolve(p);
33713371

3372+
// Check the resolved input path up front so that the lstat walk below cannot
3373+
// be used to probe the existence of paths outside the granted set.
3374+
if (permission.isEnabled() && !permission.has('fs.read', p) &&
3375+
!permission.isAuditMode()) {
3376+
throw new ERR_ACCESS_DENIED(
3377+
'Access to this API has been restricted. Use --allow-fs-read to manage permissions.',
3378+
'FileSystemRead',
3379+
p,
3380+
);
3381+
}
3382+
33723383
const cache = options[realpathCacheKey];
33733384
const maybeCachedResult = cache?.get(p);
33743385
if (maybeCachedResult) {

‎test/common/tmpdir.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,20 @@ function rmSync(pathname, useSpawn) {
3232
}
3333
}
3434

35+
// Under the permission model, fs.realpathSync() requires read access to the
36+
// path, which tests running with a restricted --allow-fs-read may not grant
37+
// for NODE_TEST_DIR.
38+
function resolveTestRoot(dir) {
39+
try {
40+
return fs.realpathSync(dir);
41+
} catch (err) {
42+
if (err.code !== 'ERR_ACCESS_DENIED') throw err;
43+
return path.resolve(dir);
44+
}
45+
}
46+
3547
const testRoot = process.env.NODE_TEST_DIR ?
36-
fs.realpathSync(process.env.NODE_TEST_DIR) : path.resolve(__dirname, '..');
48+
resolveTestRoot(process.env.NODE_TEST_DIR) : path.resolve(__dirname, '..');
3749

3850
// Using a `.` prefixed name, which is the convention for "hidden" on POSIX,
3951
// gets tools to ignore it by default or by simple rules, especially eslint.

‎test/fixtures/permission/fs-read.js‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,57 @@ const regularFile = __filename;
707707
});
708708
}
709709

710+
// fs.realpath (JS implementation, walks the path with lstat)
711+
{
712+
assert.throws(() => {
713+
fs.realpathSync(blockedFile);
714+
}, common.expectsError({
715+
code: 'ERR_ACCESS_DENIED',
716+
permission: 'FileSystemRead',
717+
}));
718+
assert.throws(() => {
719+
fs.realpathSync(bufferBlockedFile);
720+
}, common.expectsError({
721+
code: 'ERR_ACCESS_DENIED',
722+
permission: 'FileSystemRead',
723+
}));
724+
assert.throws(() => {
725+
fs.realpathSync(path.join(blockedFolder, 'anyfile'));
726+
}, common.expectsError({
727+
code: 'ERR_ACCESS_DENIED',
728+
permission: 'FileSystemRead',
729+
}));
730+
// Must not act as a file-existence oracle: a non-existent path inside a
731+
// blocked folder must be denied, not reported as ENOENT.
732+
assert.throws(() => {
733+
fs.realpathSync(path.join(blockedFolder, 'does-not-exist'));
734+
}, common.expectsError({
735+
code: 'ERR_ACCESS_DENIED',
736+
permission: 'FileSystemRead',
737+
}));
738+
fs.realpath(blockedFile, common.expectsError({
739+
code: 'ERR_ACCESS_DENIED',
740+
permission: 'FileSystemRead',
741+
}));
742+
assert.throws(() => {
743+
fs.realpathSync(blockedFile);
744+
}, common.expectsError({
745+
message: 'Access to this API has been restricted. Use --allow-fs-read to manage permissions.',
746+
code: 'ERR_ACCESS_DENIED',
747+
permission: 'FileSystemRead',
748+
resource: blockedFile,
749+
}));
750+
assert.rejects(async () => {
751+
await fsPromises.realpath(blockedFile);
752+
}, common.expectsError({
753+
code: 'ERR_ACCESS_DENIED',
754+
permission: 'FileSystemRead',
755+
})).then(common.mustCall());
756+
757+
// doesNotThrow
758+
assert.strictEqual(typeof fs.realpathSync(regularFile), 'string');
759+
}
760+
710761
// fs.realpath.native
711762
{
712763
fs.realpath.native(blockedFile, common.expectsError({
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Flags: --permission-audit
2+
'use strict';
3+
4+
const common = require('../common');
5+
const assert = require('assert');
6+
const dc = require('diagnostics_channel');
7+
const fs = require('fs');
8+
const fixtures = require('../common/fixtures');
9+
10+
const file = fixtures.path('permission', 'deny', 'protected-file.md');
11+
12+
const denied = [];
13+
dc.subscribe('node:permission-model:fs', common.mustCallAtLeast((message) => {
14+
assert.strictEqual(message.permission, 'FileSystemRead');
15+
denied.push(message.resource);
16+
}));
17+
18+
assert.strictEqual(fs.realpathSync(file), file);
19+
assert.ok(denied.includes(file), `realpathSync reported ${file}`);

‎test/parallel/test-permission-fs-require.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const { spawnSync } = require('node:child_process');
4444
);
4545

4646
assert.strictEqual(status, 1, stderr.toString());
47-
assert.match(stderr.toString(), /Error: Access to this API has been restricted/);
47+
assert.match(stderr.toString(), /Error(?: \[ERR_ACCESS_DENIED\])?: Access to this API has been restricted/);
4848
}
4949

5050
{
@@ -78,5 +78,5 @@ const { spawnSync } = require('node:child_process');
7878
);
7979

8080
assert.strictEqual(status, 1, stderr.toString());
81-
assert.match(stderr.toString(), /Error: Access to this API has been restricted/);
81+
assert.match(stderr.toString(), /Error(?: \[ERR_ACCESS_DENIED\])?: Access to this API has been restricted/);
8282
}

0 commit comments

Comments
 (0)