From 188c87643354a8de02754ff97ac47cf25da0452a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:52:13 +0000 Subject: [PATCH] fix: use .exe PATH fallback so PATHEXT cannot run node.js cmd.exe PATHEXT-expands extensionless names (cwd first). Default PATHEXT includes .JS, so a sibling node.js is executed instead of node. The existing %PATHEXT:;.JS;=;% strip only removes a middle ;.JS; entry. Look up simple command names as prog.exe (node.exe, python.exe), matching the local %dp0%\prog.exe check and the PowerShell $exe suffix. Co-authored-by: David --- lib/index.js | 16 ++- tap-snapshots/test/basic.js.test.cjs | 10 +- test/00-setup.js | 2 + test/pathext-shadow.js | 167 +++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 test/pathext-shadow.js diff --git a/lib/index.js b/lib/index.js index 1b6d1d8..d28f996 100644 --- a/lib/index.js +++ b/lib/index.js @@ -23,6 +23,20 @@ const toBatchSyntax = require('./to-batch-syntax') // eslint-disable-next-line max-len const shebangExpr = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/ +// cmd.exe PATHEXT-expands extensionless names (cwd first, then PATH). +// Default PATHEXT includes .JS, so `node` can run a sibling node.js. +// `%PATHEXT:;.JS;=;%` only strips a middle ;.JS; and misses first/last. +// A simple name with .exe is looked up as-is and cannot match name.js. +// Paths (containing \ or /) are not PATHEXT-searched. +// https://github.com/npm/cmd-shim/issues/71 +const windowsPathProg = (prog) => { + const name = prog.replace(/(^")|("$)/g, '') + if (/[\\/]/.test(name) || /\.exe$/i.test(name)) { + return name + } + return `${name}.exe` +} + const cmdShimIfExists = (from, to) => stat(from).then(() => cmdShim(from, to), () => {}) @@ -108,7 +122,7 @@ const writeShim_ = (from, to, prog, args, variables) => { + `IF EXIST ${longProg} (\r\n` + ` SET "_prog=${longProg.replace(/(^")|("$)/g, '')}"\r\n` + ') ELSE (\r\n' - + ` SET "_prog=${prog.replace(/(^")|("$)/g, '')}"\r\n` + + ` SET "_prog=${windowsPathProg(prog)}"\r\n` + ')\r\n' + '\r\n' // prevent "Terminate Batch Job? (Y/n)" message diff --git a/tap-snapshots/test/basic.js.test.cjs b/tap-snapshots/test/basic.js.test.cjs index 901a363..79b66ea 100644 --- a/tap-snapshots/test/basic.js.test.cjs +++ b/tap-snapshots/test/basic.js.test.cjs @@ -18,7 +18,7 @@ CALL :find_dp0\\r IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=%dp0%\\node.exe"\\r ) ELSE (\\r - SET "_prog=node"\\r + SET "_prog=node.exe"\\r )\\r \\r endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env" %*\\r @@ -107,7 +107,7 @@ CALL :find_dp0\\r IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=%dp0%\\node.exe"\\r ) ELSE (\\r - SET "_prog=node"\\r + SET "_prog=node.exe"\\r )\\r \\r endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.args" %*\\r @@ -197,7 +197,7 @@ CALL :find_dp0\\r IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=%dp0%\\node.exe"\\r ) ELSE (\\r - SET "_prog=node"\\r + SET "_prog=node.exe"\\r )\\r \\r endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" "%dp0%\\from.env.variables" %*\\r @@ -584,7 +584,7 @@ CALL :find_dp0\\r IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=%dp0%\\node.exe"\\r ) ELSE (\\r - SET "_prog=node"\\r + SET "_prog=node.exe"\\r )\\r \\r endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --flag-one --flag-two "%dp0%\\from.env.multiple.variables" %*\\r @@ -732,7 +732,7 @@ CALL :find_dp0\\r IF EXIST "%dp0%\\node.exe" (\\r SET "_prog=%dp0%\\node.exe"\\r ) ELSE (\\r - SET "_prog=node"\\r + SET "_prog=node.exe"\\r )\\r \\r endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & set PATHEXT=%PATHEXT:;.JS;=;% & "%_prog%" --expose_gc "%dp0%\\from.env.S" %*\\r diff --git a/test/00-setup.js b/test/00-setup.js index 5c2e6c0..084b981 100644 --- a/test/00-setup.js +++ b/test/00-setup.js @@ -15,6 +15,8 @@ const froms = { 'from.env.multiple.variables': '#!/usr/bin/env key=value key2=value2 node --flag-one --flag-two', 'from.env.S': '#!/usr/bin/env -S node --expose_gc\ngc()\n', 'from.env.nospace': '#!/usr/bin/envnode\nconsole.log(/hi/)\n', + 'from.env.python': '#!/usr/bin/env python\nprint("hi")\n', + 'from.env.nodeexe': '#!/usr/bin/env node.exe\nconsole.log(/hi/)\n', } mkdirSync(fixtures, { recursive: true }) diff --git a/test/pathext-shadow.js b/test/pathext-shadow.js new file mode 100644 index 0000000..885fbae --- /dev/null +++ b/test/pathext-shadow.js @@ -0,0 +1,167 @@ +'use strict' + +// https://github.com/npm/cmd-shim/issues/71 +// +// cmd.exe resolves an extensionless command by searching cwd first, then PATH, +// appending each PATHEXT entry. Default Windows PATHEXT includes .JS, so a +// file named node.js (cwd or earlier PATH entry) is executed instead of node. +// +// %PATHEXT:;.JS;=;% only deletes a *middle* ;.JS; entry. It misses .JS when it +// is first or last. Invoking prog.exe avoids PATHEXT entirely. + +const test = require('tap').test +const fs = require('fs') +const path = require('path') +const cmdShim = require('..') + +const fixtures = path.resolve(__dirname, 'fixtures') + +// Default Win10/11 PATHEXT (see issue #71). +const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC' + +// What today's shim substitution produces on the default list. +const STRIPPED_MIDDLE_JS = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JSE;.WSF;.WSH;.MSC' + +const applyShimPathextStrip = (pathext) => + pathext.split(';.JS;').join(';') + +// cmd.exe: for each directory (cwd first), try name+each PATHEXT entry. +// A name that already has a PATHEXT extension is used as-is. +const resolveCmdName = (name, cwdEntries, pathext) => { + const exts = pathext.split(';').filter(Boolean) + const upper = (s) => s.toUpperCase() + const nameHasExt = exts.some((ext) => upper(name).endsWith(upper(ext))) + const candidates = nameHasExt ? [name] : exts.map((ext) => name + ext) + const cwdUpper = cwdEntries.map(upper) + for (const candidate of candidates) { + const i = cwdUpper.indexOf(upper(candidate)) + if (i !== -1) { + return cwdEntries[i] + } + } + return nameHasExt ? name : `${name}.exe` +} + +const pathFallbackProg = (cmdText) => { + const m = cmdText.match(/ELSE \(\r?\n {2}SET "_prog=([^"]+)"/) + return m ? m[1] : null +} + +test('PATHEXT mock: extensionless node is shadowed by cwd node.js', (t) => { + t.equal( + resolveCmdName('node', ['node.js'], DEFAULT_PATHEXT), + 'node.js', + 'bare node + default PATHEXT picks cwd node.js' + ) + t.equal( + resolveCmdName('node', ['node.js'], STRIPPED_MIDDLE_JS), + 'node.exe', + 'removing a middle ;.JS; avoids the shadow on the default list' + ) + t.equal( + applyShimPathextStrip('.JS;.COM;.EXE'), + '.JS;.COM;.EXE', + '%PATHEXT:;.JS;=;% does not remove .JS when it is first' + ) + t.equal( + resolveCmdName('node', ['node.js'], applyShimPathextStrip('.JS;.COM;.EXE')), + 'node.js', + 'first-entry .JS still shadows node' + ) + t.equal( + applyShimPathextStrip('.COM;.EXE;.JS'), + '.COM;.EXE;.JS', + '%PATHEXT:;.JS;=;% does not remove .JS when it is last' + ) + t.equal( + resolveCmdName('node', ['node.js'], applyShimPathextStrip('.COM;.EXE;.JS')), + 'node.js', + 'last-entry .JS still shadows node' + ) + t.equal( + resolveCmdName('node.exe', ['node.js'], DEFAULT_PATHEXT), + 'node.exe', + 'node.exe is not shadowed by node.js at any PATHEXT position' + ) + t.equal( + resolveCmdName('python', ['python.js'], DEFAULT_PATHEXT), + 'python.js', + 'same root cause for other interpreter names' + ) + t.equal( + resolveCmdName('python.exe', ['python.js'], DEFAULT_PATHEXT), + 'python.exe', + 'python.exe is not shadowed by python.js' + ) + t.end() +}) + +test('cmd shim PATH fallback is node.exe so cwd node.js cannot shadow', async (t) => { + const from = path.resolve(fixtures, 'from.env') + const to = path.resolve(fixtures, 'pathext-node.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + + t.match( + cmd, + /SET "_prog=node\.exe"/, + 'PATH fallback is node.exe, not extensionless node' + ) + t.notMatch( + cmd, + /SET "_prog=node"\r/, + 'does not fall back to bare node (PATHEXT-vulnerable)' + ) + + const fallback = pathFallbackProg(cmd) + t.equal(fallback, 'node.exe') + t.equal( + resolveCmdName(fallback, ['node.js'], DEFAULT_PATHEXT), + 'node.exe', + 'generated fallback does not resolve to cwd node.js' + ) + t.equal( + resolveCmdName(fallback, ['node.js'], '.JS;.COM;.EXE'), + 'node.exe', + 'generated fallback is safe when .JS is first in PATHEXT' + ) + t.equal( + resolveCmdName(fallback, ['node.js'], '.COM;.EXE;.JS'), + 'node.exe', + 'generated fallback is safe when .JS is last in PATHEXT' + ) +}) + +test('cmd shim does not append a second .exe when shebang is already node.exe', async (t) => { + const from = path.resolve(fixtures, 'from.env.nodeexe') + const to = path.resolve(fixtures, 'pathext-nodeexe.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + t.match(cmd, /SET "_prog=node\.exe"/) + t.notMatch(cmd, /SET "_prog=node\.exe\.exe"/) + t.equal(pathFallbackProg(cmd), 'node.exe') +}) + +test('cmd shim leaves pathed interpreters unchanged', async (t) => { + const from = path.resolve(fixtures, 'from.sh') + const to = path.resolve(fixtures, 'pathext-sh.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + t.equal(pathFallbackProg(cmd), '/usr/bin/sh') + t.notMatch(cmd, /SET "_prog=\/usr\/bin\/sh\.exe"/) +}) + +test('cmd shim PATH fallback uses .exe for non-node env bins too', async (t) => { + const from = path.resolve(fixtures, 'from.env.python') + const to = path.resolve(fixtures, 'pathext-python.shim') + await cmdShim(from, to) + const cmd = fs.readFileSync(`${to}.cmd`, 'utf8') + + t.match(cmd, /SET "_prog=python\.exe"/) + const fallback = pathFallbackProg(cmd) + t.equal(fallback, 'python.exe') + t.equal( + resolveCmdName(fallback, ['python.js'], DEFAULT_PATHEXT), + 'python.exe' + ) +})