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)}`);
+}));