From b0ef4b5ab91b802f5f4b5446b2ca99534ee9f7e8 Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Fri, 2 Jan 2026 17:17:34 +0900 Subject: [PATCH 1/3] lib: fix phantom EACCES on spawn in some environments On some Linux environments (e.g. WSL), execvp may return EACCES instead of ENOENT when a command is not found in PATH, if it encounters permission errors during the search. This change adds a check to verify if the file actually exists in PATH when EACCES is received for a command without path separators. If not found, the error is normalized to ENOENT. --- lib/internal/child_process.js | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/internal/child_process.js b/lib/internal/child_process.js index 45ae95614a88b5..10fe7cc2a180bd 100644 --- a/lib/internal/child_process.js +++ b/lib/internal/child_process.js @@ -38,6 +38,8 @@ const { const EventEmitter = require('events'); const net = require('net'); const dgram = require('dgram'); +const fs = require('fs'); +const path = require('path'); const inspect = require('internal/util/inspect').inspect; const assert = require('internal/assert'); @@ -73,6 +75,34 @@ const { UV_ESRCH, } = internalBinding('uv'); +function verifyENOENT(file, envPairs) { + if (file === '.' || file.includes(path.sep)) return false; + let envPath; + if (envPairs) { + for (let i = 0; i < envPairs.length; i++) { + const pair = envPairs[i]; + if (pair.startsWith('PATH=')) { + envPath = pair.slice(5); + break; + } + } + } + if (!envPath) { + envPath = process.env.PATH; + } + if (!envPath) return false; + const paths = envPath.split(path.delimiter); + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (!p) continue; + const fullPath = path.resolve(p, file); + if (fs.existsSync(fullPath)) { + return false; + } + } + return true; +} + const { SocketListSend, SocketListReceive } = SocketList; // Lazy loaded for startup performance and to allow monkey patching of @@ -394,8 +424,9 @@ ChildProcess.prototype.spawn = function spawn(options) { const err = this._handle.spawn(options); - // Run-time errors should emit an error, not throw an exception. - if (err === UV_EACCES || + if (err === UV_EACCES && verifyENOENT(this.spawnfile, options.envPairs)) { + process.nextTick(onErrorNT, this, UV_ENOENT); + } else if (err === UV_EACCES || err === UV_EAGAIN || err === UV_EMFILE || err === UV_ENFILE || @@ -1088,6 +1119,10 @@ function maybeClose(subprocess) { function spawnSync(options) { const result = spawn_sync.spawn(options); + if (result.error === UV_EACCES && verifyENOENT(options.file, options.envPairs)) { + result.error = UV_ENOENT; + } + if (result.output && options.encoding && options.encoding !== 'buffer') { for (let i = 0; i < result.output.length; i++) { if (!result.output[i]) From 6b7473400430b8d1828585106ed703d3ebf8c8a9 Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Fri, 2 Jan 2026 17:30:17 +0900 Subject: [PATCH 2/3] test: add test for spawn path traversal with access errors Adds a test case that places inaccessible directories and files in PATH to verify that spawn correctly returns ENOENT for non-existent commands, ensuring robustness of the EACCES handling fix. --- lib/internal/child_process.js | 4 +- .../test-child-process-spawn-path-access.js | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-child-process-spawn-path-access.js diff --git a/lib/internal/child_process.js b/lib/internal/child_process.js index 10fe7cc2a180bd..e66a09d0a7762f 100644 --- a/lib/internal/child_process.js +++ b/lib/internal/child_process.js @@ -87,9 +87,7 @@ function verifyENOENT(file, envPairs) { } } } - if (!envPath) { - envPath = process.env.PATH; - } + envPath ||= process.env.PATH; if (!envPath) return false; const paths = envPath.split(path.delimiter); for (let i = 0; i < paths.length; i++) { diff --git a/test/parallel/test-child-process-spawn-path-access.js b/test/parallel/test-child-process-spawn-path-access.js new file mode 100644 index 00000000000000..b601298fe21ecb --- /dev/null +++ b/test/parallel/test-child-process-spawn-path-access.js @@ -0,0 +1,62 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Scenario: PATH contains directories that are inaccessible or are actually files. +// The system spawn (execvp) might return EACCES in these cases on some platforms. +// We want to ensure Node.js consistently reports ENOENT if the command is truly missing. + +const noPermDir = path.join(tmpdir.path, 'no-perm-dir'); +fs.mkdirSync(noPermDir); + +const fileInPath = path.join(tmpdir.path, 'file-in-path'); +fs.writeFileSync(fileInPath, ''); + +if (!common.isWindows) { + try { + fs.chmodSync(noPermDir, '000'); + } catch (e) { + // If we can't chmod (e.g. root or weird fs), skip the permission part of the test + // but keep the structure. + console.log('# Skipped chmod 000 on no-perm-dir due to error:', e.message); + } +} + +// Ensure cleanup restores permissions so tmpdir can be removed +process.prependListener('exit', () => { + if (!common.isWindows && fs.existsSync(noPermDir)) { + try { + fs.chmodSync(noPermDir, '777'); + } catch { + // Ignore cleanup errors during exit + } + } +}); + +const env = { ...process.env }; +const sep = path.delimiter; + +// Prepend the problematic entries to PATH +env.PATH = `${noPermDir}${sep}${fileInPath}${sep}${env.PATH}`; + +const command = 'command-that-does-not-exist-at-all-' + Date.now(); + +const child = cp.spawn(command, { env }); + +child.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(err.syscall, `spawn ${command}`); +})); + +// Also test sync +try { + cp.spawnSync(command, { env }); +} catch (err) { + assert.strictEqual(err.code, 'ENOENT'); +} From 1af02a15b6c5c8718533f71e0d7bb68135bc0c69 Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Fri, 2 Jan 2026 23:11:15 +0900 Subject: [PATCH 3/3] lib, test: fix lint and increase coverage for spawn EACCES handling - Use ||= operator in lib/internal/child_process.js - Add descriptive comment to empty catch block in test - Add test cases for non-executable files, empty PATH entries, and missing PATH in envPairs to cover verifyENOENT logic. Fixes: https://github.com/nodejs/node/issues/XXXXX (if applicable) --- .../test-child-process-spawn-path-access.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/parallel/test-child-process-spawn-path-access.js b/test/parallel/test-child-process-spawn-path-access.js index b601298fe21ecb..6b3da445f5f308 100644 --- a/test/parallel/test-child-process-spawn-path-access.js +++ b/test/parallel/test-child-process-spawn-path-access.js @@ -60,3 +60,46 @@ try { } catch (err) { assert.strictEqual(err.code, 'ENOENT'); } + +// Case: File exists but is not executable. Should NOT be normalized to ENOENT. +if (!common.isWindows) { + const nonExecFile = path.join(tmpdir.path, 'non-executable'); + fs.writeFileSync(nonExecFile, 'echo "should not run"'); + fs.chmodSync(nonExecFile, '644'); + + const env2 = { ...process.env, PATH: tmpdir.path }; + const child2 = cp.spawn('non-executable', { env: env2 }); + child2.on('error', common.mustCall((err) => { + // It should stay EACCES because the file actually exists + assert.strictEqual(err.code, 'EACCES'); + })); + + // Also test empty PATH entry + const env3 = { ...process.env, PATH: `${path.delimiter}${env.PATH}` }; + const child3 = cp.spawn(command, { env: env3 }); + child3.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Case: No PATH in envPairs +const env4 = { ...process.env }; +delete env4.PATH; +const child4 = cp.spawn(command, { env: env4 }); +child4.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); + +// Case: envPath ||= process.env.PATH (no env passed) +if (!common.isWindows) { + const oldPath = process.env.PATH; + process.env.PATH = `${noPermDir}${path.delimiter}${oldPath}`; + try { + const child5 = cp.spawn(command); + child5.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); + } finally { + process.env.PATH = oldPath; + } +}