Skip to content

Commit 40ed9a6

Browse files
committed
fix(@angular/cli): prevent command injection in spawn on Windows
Escape shell metacharacters when invoking package managers via cmd.exe instead of using shell: true with unsanitized arguments. The escape logic is based on cross-spawn's approach of directly invoking cmd.exe with properly escaped arguments and windowsVerbatimArguments: true.
1 parent 441c1d9 commit 40ed9a6

File tree

1 file changed

+57
-4
lines changed
  • packages/angular/cli/src/package-managers

1 file changed

+57
-4
lines changed

packages/angular/cli/src/package-managers/host.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,42 @@ import { platform, tmpdir } from 'node:os';
2020
import { join } from 'node:path';
2121
import { 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

Comments
 (0)