@@ -20,6 +20,42 @@ import { platform, tmpdir } from 'node:os';
2020import { join } from 'node:path' ;
2121import { PackageManagerError } from './error' ;
2222
23+ // cmd.exe metacharacters that need ^ escaping.
24+ // Reference: http://www.robvanderwoude.com/escapechars.php
25+ const metaCharsRegExp = / ( [ ( ) \] [ % ! ^ " ` < > & | ; , * ? ] ) / g;
26+
27+ /** Escapes a command name for safe use in cmd.exe. */
28+ function escapeCommandForCmd ( cmd : string ) : string {
29+ return cmd . replace ( metaCharsRegExp , '^$1' ) ;
30+ }
31+
32+ /**
33+ * Escapes an argument for safe use in cmd.exe.
34+ * Based on the algorithm from cross-spawn (https://github.com/moxystudio/node-cross-spawn)
35+ * and https://qntm.org/cmd
36+ */
37+ function escapeArgForCmd ( arg : string ) : string {
38+ // Convert to string
39+ arg = `${ arg } ` ;
40+
41+ // Sequence of backslashes followed by a double quote:
42+ // double up all the backslashes and escape the double quote
43+ arg = arg . replace ( / (? = ( \\ + ?) ? ) \1" / g, '$1$1\\"' ) ;
44+
45+ // Sequence of backslashes followed by the end of the string
46+ // (which will become a double quote later):
47+ // double up all the backslashes
48+ arg = arg . replace ( / (? = ( \\ + ?) ? ) \1$ / , '$1$1' ) ;
49+
50+ // Quote the whole thing
51+ arg = `"${ arg } "` ;
52+
53+ // Escape cmd.exe meta chars with ^
54+ arg = arg . replace ( metaCharsRegExp , '^$1' ) ;
55+
56+ return arg ;
57+ }
58+
2359/**
2460 * An abstraction layer for side-effectful operations.
2561 */
@@ -130,7 +166,6 @@ export const NodeJS_HOST: Host = {
130166
131167 return new Promise ( ( resolve , reject ) => {
132168 const spawnOptions = {
133- shell : isWin32 ,
134169 stdio : options . stdio ?? 'pipe' ,
135170 signal,
136171 cwd : options . cwd ,
@@ -139,9 +174,27 @@ export const NodeJS_HOST: Host = {
139174 ...options . env ,
140175 } ,
141176 } satisfies SpawnOptions ;
142- const childProcess = isWin32
143- ? spawn ( `${ command } ${ args . join ( ' ' ) } ` , spawnOptions )
144- : spawn ( command , args , spawnOptions ) ;
177+
178+ let childProcess ;
179+ if ( isWin32 ) {
180+ // On Windows, package managers (npm, yarn, pnpm) are .cmd scripts that
181+ // require a shell to execute. Instead of using shell: true (which is
182+ // vulnerable to command injection), we invoke cmd.exe directly with
183+ // properly escaped arguments.
184+ // This approach is based on cross-spawn:
185+ // https://github.com/moxystudio/node-cross-spawn
186+ const escapedCmd = escapeCommandForCmd ( command ) ;
187+ const escapedArgs = args . map ( ( a ) => escapeArgForCmd ( a ) ) ;
188+ const shellCommand = [ escapedCmd , ...escapedArgs ] . join ( ' ' ) ;
189+
190+ childProcess = spawn (
191+ process . env . comspec || 'cmd.exe' ,
192+ [ '/d' , '/s' , '/c' , `"${ shellCommand } "` ] ,
193+ { ...spawnOptions , windowsVerbatimArguments : true } ,
194+ ) ;
195+ } else {
196+ childProcess = spawn ( command , args , spawnOptions ) ;
197+ }
145198
146199 let stdout = '' ;
147200 childProcess . stdout ?. on ( 'data' , ( data ) => ( stdout += data . toString ( ) ) ) ;
0 commit comments