Authorization bypass / Path disclosure via realpath

HIGH
nodejs/node
Commit: e4f3c20be260
Affected: < 25.9.0 (pre-fix)
2026-04-05 10:21 UTC

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
← Back to Alerts View on GitHub →