@@ -64,6 +64,33 @@ function splitList(value: string, separator: string): string[] {
6464 . filter ( ( entry ) => entry . length > 0 ) ;
6565}
6666
67+ /**
68+ * Splits a Windows PATH the way cmd.exe reads it: a `;` inside a quoted
69+ * component does not separate entries, the surrounding quotes are
70+ * stripped, and an empty component stands for the current directory.
71+ * Plain `String.split(';')` would shred quoted entries whose directory
72+ * names contain `;` and silently drop current-directory entries.
73+ */
74+ function splitWindowsPath ( pathEnv : string ) : string [ ] {
75+ const parts : string [ ] = [ ] ;
76+ let current = '' ;
77+ let inQuotes = false ;
78+ for ( const char of pathEnv ) {
79+ if ( char === '"' ) {
80+ inQuotes = ! inQuotes ;
81+ continue ;
82+ }
83+ if ( char === path . delimiter && ! inQuotes ) {
84+ parts . push ( current ) ;
85+ current = '' ;
86+ continue ;
87+ }
88+ current += char ;
89+ }
90+ parts . push ( current ) ;
91+ return parts . map ( ( part ) => ( part === '' ? '.' : part ) ) ;
92+ }
93+
6794/**
6895 * Resolve a bare command name against PATH and PATHEXT the way cmd.exe
6996 * does, so spawn() can launch it on Windows.
@@ -76,9 +103,12 @@ function splitList(value: string, separator: string): string[] {
76103 * shell, which silently breaks bun-based flows such as the auto-updater.
77104 *
78105 * Walks PATH entries in order; within each entry, tries PATHEXT
79- * extensions in declared order. The first directory containing any match
80- * wins, and the matched extension decides whether the file is directly
81- * spawnable (`.exe`/`.com`) or must run through cmd.exe (`.cmd`/`.bat`).
106+ * extensions in declared order. PATH entries are split the way cmd.exe
107+ * reads them (quoted entries may contain `;`, empty entries mean the
108+ * current directory — see splitWindowsPath). The first directory
109+ * containing any match wins, and the matched extension decides whether
110+ * the file is directly spawnable (`.exe`/`.com`) or must run through
111+ * cmd.exe (`.cmd`/`.bat`).
82112 */
83113export function resolveWindowsCommand (
84114 command : string ,
@@ -98,7 +128,7 @@ export function resolveWindowsCommand(
98128 candidate . toLowerCase ( ) ,
99129 ) ;
100130
101- for ( const dir of splitList ( pathEnv , path . delimiter ) ) {
131+ for ( const dir of splitWindowsPath ( pathEnv ) ) {
102132 let entries : string [ ] ;
103133 try {
104134 entries = readdirSync ( dir ) ;
@@ -125,22 +155,62 @@ export function resolveWindowsCommand(
125155 return undefined ;
126156}
127157
158+ /**
159+ * cmd.exe metacharacters neutralised by double-quoting the argument.
160+ * Inside double quotes, `& | < > ( ) ^ !` are literal to cmd; unquoted
161+ * they split or chain commands (verified on Windows: passing `a&echo x`
162+ * as a bare token makes cmd execute the second command).
163+ */
164+ const CMD_METACHARACTERS = / [ \s " & | < > ( ) ^ ! ] / ;
165+
166+ /**
167+ * Detects characters that cannot be passed through `cmd.exe /c` at
168+ * all: `%` expands environment variables even inside double quotes and
169+ * has no escape on the cmd command line, and control characters corrupt
170+ * the line. Node.js rejects the same inputs with EINVAL when spawning
171+ * .cmd/.bat files (CVE-2024-27980 hardening); we throw instead of
172+ * letting cmd.exe reinterpret the argument.
173+ */
174+ function isCmdUnsafeArgument ( arg : string ) : boolean {
175+ if ( arg . includes ( '%' ) ) return true ;
176+ for ( let i = 0 ; i < arg . length ; i ++ ) {
177+ if ( arg . charCodeAt ( i ) <= 0x1f ) return true ;
178+ }
179+ return false ;
180+ }
181+
128182/**
129183 * Quotes one argument for a `cmd.exe /c` command line. Arguments that
130- * contain no spaces or quotes are passed through untouched — cmd's /s
131- * stripping mangles gratuitously quoted tokens. Callers must not pass
132- * untrusted input — cmd.exe expands `%VAR%` even inside quotes.
184+ * contain no metacharacters are passed through untouched — cmd's /s
185+ * stripping mangles gratuitously quoted tokens.
186+ *
187+ * Throws on arguments that cmd.exe cannot represent faithfully (`%`,
188+ * control characters) so callers fail loudly instead of executing an
189+ * altered command line.
133190 */
134191function escapeWindowsArgument ( arg : string ) : string {
135- if ( ! / [ \s " ] / . test ( arg ) ) {
192+ if ( isCmdUnsafeArgument ( arg ) ) {
193+ throw new Error (
194+ `cannot pass ${ JSON . stringify ( arg ) } through a .cmd shim: cmd.exe reinterprets '%' and control characters even inside quotes` ,
195+ ) ;
196+ }
197+ if ( ! CMD_METACHARACTERS . test ( arg ) ) {
136198 return arg ;
137199 }
138200 const escaped = arg . replace ( / ( \\ * ) " / g, '$1$1\\"' ) . replace ( / ( \\ * ) $ / , '$1$1' ) ;
139201 return `"${ escaped } "` ;
140202}
141203
142- function buildWindowsCommandLine ( file : string , args : string [ ] ) : string {
143- return [ file , ...args ] . map ( escapeWindowsArgument ) . join ( ' ' ) ;
204+ /**
205+ * Builds the full command line handed to `cmd.exe /d /s /c`. The whole
206+ * line is wrapped in one outer pair of quotes because cmd's /s
207+ * processing strips the first and the last quote of the /c payload:
208+ * without the outer pair, a spaced path like `"C:\Program
209+ * Files\...\bun.cmd"` loses its quotes and the spawn fails (verified on
210+ * Windows). Exported for unit tests only.
211+ */
212+ export function buildWindowsCommandLine ( file : string , args : string [ ] ) : string {
213+ return `"${ [ file , ...args ] . map ( escapeWindowsArgument ) . join ( ' ' ) } "` ;
144214}
145215
146216/**
0 commit comments