Authorization bypass / Path disclosure via realpath
Description
The commit adds a permission check around realpath.native for both async and sync code paths, enforcing FileSystemRead permissions before resolving a path. This fixes an authorization bypass where an unprivileged caller could trigger path disclosure by resolving arbitrary filesystem paths via realpath without the required read permission. The change aligns realpath usage with other FS operations by denying access when the caller lacks FileSystemRead permission.
Proof of Concept
Proof-of-Concept (demonstrates potential pre-fix path-disclosure via realpath.native):
Prerequisites:
- Unix-like environment
- Node.js installed
- A test sandbox under /tmp
Setup (manual steps):
1) Create a test directory structure:
- /tmp/perm_test/public (readable by all) -> chmod 755
- /tmp/perm_test/private (restricted) -> chmod 700
- /tmp/perm_test/private/secret.txt (a dummy file)
Note: Ensure this script is run as the owner of these directories.
2) Attempt to resolve the path using Node's realpath.native:
Code (Node.js):
const fs = require('fs');
const path = require('path');
const blocked = '/tmp/perm_test/private/secret.txt';
const allowed = '/tmp/perm_test/public/secret.txt';
function test() {
// Before fix hypothesis: realpath.native might disclose the absolute path for a blocked path
try {
fs.realpath.native(blocked, (err, res) => {
if (err) {
console.log('pre-fix: blocked - error:', err.code);
} else {
console.log('pre-fix: blocked - resolved:', res);
}
});
} catch (e) {
console.log('pre-fix: blocked - exception', e);
}
// After fix: expect an access denied error for blocked paths
try {
fs.realpath.native(blocked, (err, res) => {
if (err) console.log('post-fix: blocked - error:', err.code, err);
else console.log('post-fix: blocked - resolved:', res);
});
} catch (e) {
console.log('post-fix: blocked - exception', e);
}
// Permitted path should still resolve
try {
fs.realpath.native(allowed, (err, res) => {
if (err) console.log('allowed - error:', err.code);
else console.log('allowed - resolved:', res);
});
} catch (e) {
console.log('allowed - exception', e);
}
}
test();
Notes:
- The pre-fix scenario is environment-dependent and may disclose the absolute path for a path inside a restricted directory.
- The post-fix scenario should fail with ERR_ACCESS_DENIED and a permission reference to FileSystemRead for the blocked path, preventing path disclosure.
Commit Details
Author: RafaelGSS
Date: 2026-01-05 21:18 UTC
Message:
permission: add permission check to realpath.native
Signed-off-by: RafaelGSS <rafael.nunu@hotmail.com>
PR-URL: https://github.com/nodejs-private/node-private/pull/794
Refs: https://hackerone.com/reports/3480841
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Juan José Arboleda <soyjuanarbol@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
CVE-ID: CVE-2026-21715
Triage Assessment
Vulnerability Type: Authorization bypass
Confidence: HIGH
Reasoning:
The commit adds permission checks around realpath.native for both async and sync paths, enforcing FileSystemRead permissions before performing realpath. This directly mitigates unauthorized file read access via realpath, addressing an access control vulnerability related to reading filesystem paths.
Verification Assessment
Vulnerability Type: Authorization bypass / Path disclosure via realpath
Confidence: HIGH
Affected Versions: < 25.9.0 (pre-fix)
Code Diff
diff --git a/src/node_file.cc b/src/node_file.cc
index 0fe01e8b08127c..b9e7aba56e9ad1 100644
--- a/src/node_file.cc
+++ b/src/node_file.cc
@@ -1990,11 +1990,18 @@ static void RealPath(const FunctionCallbackInfo<Value>& args) {
if (argc > 2) { // realpath(path, encoding, req)
FSReqBase* req_wrap_async = GetReqWrap(args, 2);
CHECK_NOT_NULL(req_wrap_async);
+ ASYNC_THROW_IF_INSUFFICIENT_PERMISSIONS(
+ env,
+ req_wrap_async,
+ permission::PermissionScope::kFileSystemRead,
+ path.ToStringView());
FS_ASYNC_TRACE_BEGIN1(
UV_FS_REALPATH, req_wrap_async, "path", TRACE_STR_COPY(*path))
AsyncCall(env, req_wrap_async, args, "realpath", encoding, AfterStringPtr,
uv_fs_realpath, *path);
} else { // realpath(path, encoding, undefined, ctx)
+ THROW_IF_INSUFFICIENT_PERMISSIONS(
+ env, permission::PermissionScope::kFileSystemRead, path.ToStringView());
FSReqWrapSync req_wrap_sync("realpath", *path);
FS_SYNC_TRACE_BEGIN(realpath);
int err =
diff --git a/test/fixtures/permission/fs-read.js b/test/fixtures/permission/fs-read.js
index 22f4c4184ae891..fa2bc14e443f99 100644
--- a/test/fixtures/permission/fs-read.js
+++ b/test/fixtures/permission/fs-read.js
@@ -496,4 +496,18 @@ const regularFile = __filename;
fs.lstat(regularFile, (err) => {
assert.ifError(err);
});
+}
+
+// fs.realpath.native
+{
+ fs.realpath.native(blockedFile, common.expectsError({
+ code: 'ERR_ACCESS_DENIED',
+ permission: 'FileSystemRead',
+ resource: path.toNamespacedPath(blockedFile),
+ }));
+
+ // doesNotThrow
+ fs.realpath.native(regularFile, (err) => {
+ assert.ifError(err);
+ });
}
\ No newline at end of file