Commit 3ca944c1f4f for nodejs
commit 3ca944c1f4fcd808b845b16b60b7932fd32aeb59
Author: James M Snell <jasnell@gmail.com>
Date: Fri Sep 25 10:21:04 2026 -0700
src: ensure Socket(fd) cannot bypass allow-net permission
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66117
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
diff --git a/src/env-inl.h b/src/env-inl.h
index 1423767e398..c2abf1c29a8 100644
--- a/src/env-inl.h
+++ b/src/env-inl.h
@@ -322,6 +322,10 @@ inline void Environment::set_env_vars(std::shared_ptr<KVStore> env_vars) {
env_vars_ = env_vars;
}
+inline int Environment::ipc_channel_fd() const {
+ return ipc_channel_fd_;
+}
+
inline bool Environment::printed_error() const {
return printed_error_;
}
diff --git a/src/env.cc b/src/env.cc
index 24cd7b62e4f..d8851d01172 100644
--- a/src/env.cc
+++ b/src/env.cc
@@ -32,6 +32,7 @@
#include <algorithm>
#include <atomic>
+#include <charconv>
#include <cinttypes>
#include <cstdio>
#include <iostream>
@@ -1044,6 +1045,21 @@ Environment::Environment(IsolateData* isolate_data,
// which may or may not be the system environment variable store.
enabled_debug_list_.Parse(this);
+ if (is_main_thread()) {
+ // setupChildProcessIpcChannel() in lib/internal/process/pre_execution.js
+ // adopts the IPC channel passed by the parent process and then removes
+ // NODE_CHANNEL_FD from the environment. Record the descriptor before any
+ // JavaScript runs, so that later changes to the environment cannot affect
+ // which descriptor is treated as the IPC channel.
+ std::optional<std::string> channel_fd = env_vars()->Get("NODE_CHANNEL_FD");
+ if (channel_fd.has_value()) {
+ int fd;
+ const char* begin = channel_fd->data();
+ auto result = std::from_chars(begin, begin + channel_fd->size(), fd);
+ if (result.ec == std::errc() && fd >= 0) ipc_channel_fd_ = fd;
+ }
+ }
+
heap_snapshot_near_heap_limit_ =
static_cast<uint32_t>(options_->heap_snapshot_near_heap_limit);
diff --git a/src/env.h b/src/env.h
index 57f8369f598..b94e2d8dc9e 100644
--- a/src/env.h
+++ b/src/env.h
@@ -827,6 +827,10 @@ class Environment final : public MemoryRetainer {
inline std::shared_ptr<KVStore> env_vars();
inline void set_env_vars(std::shared_ptr<KVStore> env_vars);
+ // The IPC channel descriptor passed by the parent process through
+ // NODE_CHANNEL_FD when this Environment was created, or -1.
+ inline int ipc_channel_fd() const;
+
inline IsolateData* isolate_data() const;
inline bool printed_error() const;
@@ -1249,6 +1253,7 @@ class Environment final : public MemoryRetainer {
permission::Permission permission_;
const uint64_t timer_base_;
std::shared_ptr<KVStore> env_vars_;
+ int ipc_channel_fd_ = -1;
bool printed_error_ = false;
bool trace_sync_io_ = false;
bool emit_env_nonstring_warning_ = true;
diff --git a/src/pipe_wrap.cc b/src/pipe_wrap.cc
index 99e5729cfc1..4021a59ecb2 100644
--- a/src/pipe_wrap.cc
+++ b/src/pipe_wrap.cc
@@ -217,6 +217,14 @@ void PipeWrap::Open(const FunctionCallbackInfo<Value>& args) {
int fd;
if (!args[0]->Int32Value(env->context()).To(&fd)) return;
+ // Adopting an existing descriptor gives access to whatever it is connected
+ // to, so, like bind(), listen() and connect(), it requires the net
+ // permission.
+ if (!IsProcessStdioOrIPCChannel(env, fd)) {
+ THROW_IF_INSUFFICIENT_PERMISSIONS(
+ env, permission::PermissionScope::kNet, "");
+ }
+
int err = uv_pipe_open(&wrap->handle_, fd);
if (err == 0) wrap->set_fd(fd);
diff --git a/src/stream_wrap.cc b/src/stream_wrap.cc
index b874367e654..e2ffaa0f4e1 100644
--- a/src/stream_wrap.cc
+++ b/src/stream_wrap.cc
@@ -188,6 +188,9 @@ LibuvStreamWrap* LibuvStreamWrap::From(Environment* env, Local<Object> object) {
return Unwrap<LibuvStreamWrap>(object);
}
+bool LibuvStreamWrap::IsProcessStdioOrIPCChannel(Environment* env, int fd) {
+ return fd >= 0 && (fd <= 2 || fd == env->ipc_channel_fd());
+}
int LibuvStreamWrap::GetFD() {
#ifdef _WIN32
diff --git a/src/stream_wrap.h b/src/stream_wrap.h
index 93db2a9e686..b06b4be6988 100644
--- a/src/stream_wrap.h
+++ b/src/stream_wrap.h
@@ -103,6 +103,12 @@ class LibuvStreamWrap : public HandleWrap, public StreamBase {
#endif
}
+ // Whether `fd` is a descriptor that the process was started with and that
+ // Node.js adopts on its behalf: one of the standard streams, backing
+ // process.stdin, process.stdout and process.stderr, or the IPC channel
+ // passed through NODE_CHANNEL_FD, backing process.send(). Adopting any other
+ // existing descriptor into a stream handle requires the net permission.
+ static bool IsProcessStdioOrIPCChannel(Environment* env, int fd);
private:
static void GetWriteQueueSize(
diff --git a/src/tcp_wrap.cc b/src/tcp_wrap.cc
index 68f28e3dc87..68439487700 100644
--- a/src/tcp_wrap.cc
+++ b/src/tcp_wrap.cc
@@ -375,10 +375,20 @@ void TCPWrap::Open(const FunctionCallbackInfo<Value>& args) {
TCPWrap* wrap;
ASSIGN_OR_RETURN_UNWRAP(
&wrap, args.This(), args.GetReturnValue().Set(UV_EBADF));
+ Environment* env = wrap->env();
int64_t val;
if (!args[0]->IntegerValue(args.GetIsolate()->GetCurrentContext()).To(&val))
return;
int fd = static_cast<int>(val);
+
+ // Adopting an existing descriptor gives access to whatever it is connected
+ // to, so, like bind(), listen() and connect(), it requires the net
+ // permission.
+ if (!IsProcessStdioOrIPCChannel(env, fd)) {
+ THROW_IF_INSUFFICIENT_PERMISSIONS(
+ env, permission::PermissionScope::kNet, "");
+ }
+
int err = uv_tcp_open(&wrap->handle_, fd);
if (err == 0) wrap->set_fd(fd);
diff --git a/test/parallel/test-permission-net-cluster.js b/test/parallel/test-permission-net-cluster.js
new file mode 100644
index 00000000000..192f2b86ad3
--- /dev/null
+++ b/test/parallel/test-permission-net-cluster.js
@@ -0,0 +1,37 @@
+'use strict';
+
+// Cluster workers started with --permission and without --allow-net can use
+// their IPC channel and standard streams, which Node.js adopts without the net
+// permission.
+
+const common = require('../common');
+const assert = require('assert');
+const cluster = require('cluster');
+
+if (cluster.isPrimary) {
+ cluster.setupPrimary({
+ execArgv: ['--permission', '--allow-fs-read=*'],
+ silent: true,
+ });
+ const worker = cluster.fork();
+ let stdout = '';
+ let stderr = '';
+ worker.process.stdout.setEncoding('utf8');
+ worker.process.stdout.on('data', (chunk) => { stdout += chunk; });
+ worker.process.stderr.setEncoding('utf8');
+ worker.process.stderr.on('data', (chunk) => { stderr += chunk; });
+ worker.on('online', common.mustCall());
+ worker.on('message', common.mustCall((message) => {
+ assert.strictEqual(message, 'ready');
+ worker.disconnect();
+ }));
+ worker.process.on('close', common.mustCall((code, signal) => {
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(stdout, 'stdout');
+ }));
+} else {
+ assert.strictEqual(process.permission.has('net'), false);
+ process.stdout.write('stdout');
+ process.send('ready');
+}
diff --git a/test/parallel/test-permission-net-socket-fd.js b/test/parallel/test-permission-net-socket-fd.js
new file mode 100644
index 00000000000..323cc281625
--- /dev/null
+++ b/test/parallel/test-permission-net-socket-fd.js
@@ -0,0 +1,153 @@
+'use strict';
+
+// Adopting an existing socket descriptor, as net.Socket({ fd }) and
+// server.listen({ fd }) do, requires the net permission. The standard streams
+// and the IPC channel of the process can still be adopted without it.
+
+const common = require('../common');
+if (common.isWindows) {
+ common.skip('Socket descriptors cannot be passed through stdio on Windows');
+}
+
+const assert = require('assert');
+const { fork, spawn, spawnSync } = require('child_process');
+const net = require('net');
+const tmpdir = require('../common/tmpdir');
+
+if (process.argv[2] === 'fork-child') {
+ assert.strictEqual(process.permission.has('net'), false);
+ process.stdout.write('stdout');
+ process.once('message', common.mustCall((message) => {
+ assert.strictEqual(message, 'ping');
+ process.send('pong', () => process.disconnect());
+ }));
+ process.send('ready');
+ return;
+}
+
+// The descriptor to adopt is passed as the first argument. Changing
+// NODE_CHANNEL_FD at runtime must not make it count as the IPC channel.
+const denied = `
+ const assert = require('node:assert');
+ const net = require('node:net');
+ const fd = Number(process.argv[1]);
+ process.env.NODE_CHANNEL_FD = String(fd);
+ const expected = { code: 'ERR_ACCESS_DENIED', permission: 'Net' };
+ assert.throws(() => new net.Socket({ fd }), expected);
+ assert.throws(() => net.createServer().listen({ fd }), expected);
+ process.send?.('done');
+`;
+
+const allowed = `
+ const net = require('node:net');
+ new net.Socket({ fd: Number(process.argv[1]) }).destroy();
+`;
+
+function checkChild(execArgv, source, fd) {
+ const { status, signal, stderr } = spawnSync(
+ process.execPath,
+ [...execArgv, '--eval', source, '3'],
+ { stdio: ['ignore', 'ignore', 'pipe', fd] },
+ );
+ assert.strictEqual(signal, null);
+ assert.strictEqual(status, 0, stderr.toString());
+}
+
+tmpdir.refresh();
+
+// Connected TCP and Unix domain sockets.
+for (const options of [{ host: '127.0.0.1', port: 0 }, { path: common.PIPE }]) {
+ let client;
+ const server = net.createServer(common.mustCall((socket) => {
+ checkChild(['--permission'], denied, socket._handle.fd);
+ checkChild(['--permission', '--allow-net'], allowed, socket._handle.fd);
+ socket.destroy();
+ client.destroy();
+ server.close();
+ }));
+ server.listen(options, common.mustCall(() => {
+ const { port } = server.address();
+ client = net.connect(options.path ?? { ...options, port });
+ }));
+}
+
+// A listening TCP socket.
+{
+ const server = net.createServer(common.mustNotCall());
+ server.listen(0, '127.0.0.1', common.mustCall(() => {
+ checkChild(['--permission'], denied, server._handle.fd);
+ server.close();
+ }));
+}
+
+// The standard streams are adopted without --allow-net.
+{
+ const { status, signal, stdout, stderr } = spawnSync(process.execPath, [
+ '--permission',
+ '--eval',
+ 'process.stdin.pipe(process.stdout); process.stderr.write("stderr");',
+ ], { input: 'stdin' });
+ assert.strictEqual(signal, null);
+ assert.strictEqual(status, 0, stderr.toString());
+ assert.strictEqual(stdout.toString(), 'stdin');
+ assert.strictEqual(stderr.toString(), 'stderr');
+}
+
+// fork() children get their IPC channel and standard streams without
+// --allow-net, including when the IPC channel is not on fd 3.
+for (const stdio of [
+ ['pipe', 'pipe', 'pipe', 'ipc'],
+ ['pipe', 'pipe', 'pipe', 'ignore', 'ipc'],
+]) {
+ const child = fork(__filename, ['fork-child'], {
+ execArgv: ['--permission', '--allow-fs-read=*'],
+ stdio,
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ child.on('message', common.mustCall((message) => {
+ if (message === 'ready') {
+ child.send('ping');
+ } else {
+ assert.strictEqual(message, 'pong');
+ }
+ }, 2));
+ child.on('close', common.mustCall((code, signal) => {
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(stdout, 'stdout');
+ }));
+}
+
+// The IPC channel is adopted without --allow-net, but other descriptors of a
+// child with an IPC channel still require it.
+{
+ let client;
+ const server = net.createServer(common.mustCall((socket) => {
+ const child = spawn(
+ process.execPath,
+ ['--permission', '--eval', denied, '4'],
+ { stdio: ['ignore', 'ignore', 'pipe', 'ipc', socket._handle.fd] },
+ );
+ let stderr = '';
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ child.on('message', common.mustCall((message) => {
+ assert.strictEqual(message, 'done');
+ }));
+ child.on('exit', common.mustCall((code, signal) => {
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stderr);
+ socket.destroy();
+ client.destroy();
+ server.close();
+ }));
+ }));
+ server.listen(0, '127.0.0.1', common.mustCall(() => {
+ client = net.connect(server.address().port, '127.0.0.1');
+ }));
+}