Commit ab6a046cc29 for nodejs
commit ab6a046cc296e30e999e4b4063467cc989979534
Author: RafaelGSS <rafael.nunu@hotmail.com>
Date: Tue Aug 11 12:37:54 2026 -0300
permission: do not leak denied descendants in recursive fs.watch
Signed-off-by: RafaelGSS <rafael.nunu@hotmail.com>
PR-URL: https://github.com/nodejs-private/node-private/pull/967
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
CVE-ID: CVE-2026-123829
diff --git a/src/fs_event_wrap.cc b/src/fs_event_wrap.cc
index 464fea7a612..ab2174bf814 100644
--- a/src/fs_event_wrap.cc
+++ b/src/fs_event_wrap.cc
@@ -27,6 +27,8 @@
#include "permission/permission.h"
#include "string_bytes.h"
+#include <string>
+
namespace node {
using v8::Context;
@@ -75,6 +77,9 @@ class FSEventWrap: public HandleWrap {
uv_fs_event_t handle_;
enum encoding encoding_ = kDefaultEncoding;
+ // The path being watched, used to re-check read permission for the
+ // descendants reported by libuv when the permission model is enabled.
+ std::string watched_path_;
};
@@ -150,6 +155,7 @@ void FSEventWrap::Start(const FunctionCallbackInfo<Value>& args) {
CHECK_NOT_NULL(*path);
THROW_IF_INSUFFICIENT_PERMISSIONS(
env, permission::PermissionScope::kFileSystemRead, *path);
+ wrap->watched_path_ = *path;
unsigned int flags = 0;
if (args[2]->IsTrue())
@@ -217,6 +223,20 @@ void FSEventWrap::OnEvent(uv_fs_event_t* handle, const char* filename,
Null(env->isolate())
};
+ // When the permission model is enabled, a recursive watch can report
+ // descendants that fall outside the granted read scope (for example a nested
+ // path under an exact, non-wildcard grant). Do not leak those names; report
+ // the event without a filename instead.
+ if (filename != nullptr && env->permission()->enabled()) {
+ std::string full_path = wrap->watched_path_;
+ full_path += kPathSeparator;
+ full_path += filename;
+ if (!env->permission()->is_granted(
+ env, permission::PermissionScope::kFileSystemRead, full_path)) {
+ filename = nullptr;
+ }
+ }
+
if (filename != nullptr) {
// TODO(@jasnell): Historically, this code has failed to correctly
// propagate any error returned by the StringBytes::Encode method,
diff --git a/test/parallel/test-permission-fs-watch-recursive.js b/test/parallel/test-permission-fs-watch-recursive.js
new file mode 100644
index 00000000000..d3f1fd12983
--- /dev/null
+++ b/test/parallel/test-permission-fs-watch-recursive.js
@@ -0,0 +1,73 @@
+'use strict';
+
+// Under the permission model, a recursive fs.watch() on a directory granted
+// with an exact (non-wildcard) read scope must not leak the names of nested
+// descendants the process is not allowed to read.
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+ common.skip('no crypto');
+
+// Recursive fs.watch() is backed by native OS facilities only on macOS and
+// Windows; elsewhere it uses a JS fallback that goes through the regular,
+// already permission-checked fs APIs.
+if (!common.isMacOS && !common.isWindows)
+ common.skip('recursive fs.watch is only native on macOS and Windows');
+
+const assert = require('assert');
+const path = require('path');
+const { spawn } = require('child_process');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+
+// `watched` does not exist yet, so the grant stays exact (no wildcard is added
+// for a non-existent directory) and its future children are not readable.
+const watched = tmpdir.resolve('watched');
+
+const child = spawn(process.execPath, [
+ '--permission',
+ `--allow-fs-read=${watched}`,
+ `--allow-fs-write=${watched}`,
+ '-e',
+ `
+ const fs = require('fs');
+ const path = require('path');
+ const root = ${JSON.stringify(watched)};
+ fs.mkdirSync(root);
+ const names = [];
+ const w = fs.watch(root, { recursive: true }, (event, filename) => {
+ if (filename !== null && filename !== undefined) names.push(filename);
+ });
+ process.stdout.write('READY\\n');
+ setTimeout(() => { w.close(); process.stdout.write('NAMES:' + JSON.stringify(names) + '\\n'); }, 1500);
+ `,
+], { encoding: 'utf8' });
+
+let stdout = '';
+let created = false;
+child.stdout.setEncoding('utf8');
+child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ if (!created && stdout.includes('READY\n')) {
+ created = true;
+ // As an unrestricted parent, create a nested descendant the child is not
+ // allowed to read (child can only create `watched` itself, not children).
+ const fs = require('fs');
+ fs.mkdirSync(path.join(watched, 'sub'));
+ fs.writeFileSync(path.join(watched, 'sub', 'secret.txt'), 'secret');
+ }
+});
+
+child.on('exit', common.mustCall((code) => {
+ assert.strictEqual(code, 0, `child exited with ${code}\n${stdout}`);
+ const match = stdout.match(/NAMES:(.*)/);
+ assert(match, `missing NAMES output:\n${stdout}`);
+ const names = JSON.parse(match[1]);
+
+ // The nested, unreadable descendant name must not have been delivered.
+ const leaked = names.some((n) => n.replaceAll(path.sep, '/').includes('sub/secret.txt'));
+ assert.strictEqual(leaked, false,
+ `recursive watch leaked a denied descendant: ${JSON.stringify(names)}`);
+}));