diff --git a/src/unix/pty.cc b/src/unix/pty.cc index 54631ad5..a20bde53 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -37,6 +37,8 @@ /* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */ #if defined(__linux__) #include +#include +#include #elif defined(__APPLE__) #include #elif defined(__FreeBSD__) @@ -110,6 +112,40 @@ struct ExitEvent { int exit_code = 0, signal_code = 0; }; +#if defined(__linux__) + +static int +SetCloseOnExec(int fd) { + int flags = fcntl(fd, F_GETFD, 0); + if (flags == -1) + return flags; + if (flags & FD_CLOEXEC) + return 0; + return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +} + +/** + * Close all file descriptors >= 3 to prevent FD leakage to child processes. + * Uses close_range() syscall on Linux 5.9+, falls back to /proc/self/fd iteration. + */ +static void +pty_close_inherited_fds() { + // Try close_range() first (Linux 5.9+, glibc 2.34+) + #if defined(SYS_close_range) + if (syscall(SYS_close_range, 3, ~0U, CLOSE_RANGE_CLOEXEC) == 0) { + return; + } + #endif + + int fd; + // Set the CLOEXEC flag on all open descriptors. Unconditionally try the first + // 16 file descriptors. After that, bail out after the first error. + for (fd = 3; ; fd++) + if (SetCloseOnExec(fd) && fd > 15) + break; +} +#endif + void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) { std::thread *th = new std::thread; // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE. @@ -433,6 +469,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { } } + // Close inherited FDs to prevent leaking pty master FDs to child + pty_close_inherited_fds(); + { char **old = environ; environ = env; diff --git a/src/unixTerminal.test.ts b/src/unixTerminal.test.ts index 69647468..e4ff9a0e 100644 --- a/src/unixTerminal.test.ts +++ b/src/unixTerminal.test.ts @@ -256,6 +256,34 @@ if (process.platform !== 'win32') { }); }); describe('spawn', () => { + if (process.platform === 'linux') { + it('should not leak pty file descriptors to child processes', (done) => { + // Spawn 3 ptys - the 3rd should not see FDs from the first two + const ptys: UnixTerminal[] = []; + for (let i = 0; i < 3; i++) { + ptys.push(new UnixTerminal('/bin/bash', [], {})); + } + + let output = ''; + ptys[2].onData((data) => { + output += data; + }); + + // Check for ptmx FDs in the 3rd terminal's shell + ptys[2].write('echo "PTMX_COUNT:$(file /proc/$$/fd/* 2>/dev/null | grep -c ptmx)"\n'); + + setTimeout(() => { + for (const pty of ptys) { + pty.kill(); + } + // Extract the count from output - should be 0 + const match = output.match(/PTMX_COUNT:(\d+)/); + assert.ok(match, `Could not find PTMX_COUNT in output: ${output}`); + assert.strictEqual(match![1], '0', `Expected 0 ptmx FDs but got ${match![1]}`); + done(); + }, 1000); + }); + } if (process.platform === 'darwin') { it('should return the name of the process', (done) => { const term = new UnixTerminal('/bin/echo');