Skip to content

Commit 5f88d87

Browse files
committed
fix(cli): mitigate CWE-78 command injection on Windows
1 parent 029e01f commit 5f88d87

File tree

5 files changed

+38
-9
lines changed

5 files changed

+38
-9
lines changed

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export const NodeJS_HOST: Host = {
131131

132132
return new Promise((resolve, reject) => {
133133
const spawnOptions = {
134-
shell: isWin32,
134+
shell: false,
135135
stdio: options.stdio ?? 'pipe',
136136
signal,
137137
cwd: options.cwd,
@@ -144,10 +144,14 @@ export const NodeJS_HOST: Host = {
144144
},
145145
} satisfies SpawnOptions;
146146

147-
// FIXED: Use safe array form to prevent OS Command Injection (CWE-78)
148-
// Previously used unsafe string concatenation on Windows (`shell: true` + args.join)
149-
// Now always uses safe array form while preserving shell behavior
150-
const childProcess = spawn(command, args, spawnOptions);
147+
// FIXED (CWE-78): On Windows npm/yarn/pnpm are .cmd scripts that need a shell.
148+
// spawn(cmd, args, {shell:true}) has Node.js join the args internally — the
149+
// previous PR only removed the explicit join; injection still existed.
150+
// Correct fix: invoke cmd.exe directly with shell:false. cmd.exe is a real
151+
// .exe so no shell wrapper is needed. Args as array = no metachar injection.
152+
const childProcess = isWin32
153+
? spawn('cmd.exe', ['/d', '/s', '/c', command, ...args], spawnOptions)
154+
: spawn(command, args, spawnOptions);
151155

152156
let stdout = '';
153157
childProcess.stdout?.on('data', (data) => (stdout += data.toString()));

packages/angular_devkit/build_angular/src/builders/ssr-dev-server/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ function startNodeServer(
238238

239239
return of(null).pipe(
240240
delay(0), // Avoid EADDRINUSE error since it will cause the kill event to be finish.
241-
switchMap(() => spawnAsObservable('node', args, { env, shell: true })),
241+
// shell:true removed — spawnAsObservable handles Windows safely via cmd.exe
242+
switchMap(() => spawnAsObservable('node', args, { env })),
242243
tap((res) => log({ stderr: res.stderr, stdout: res.stdout }, logger)),
243244
ignoreElements(),
244245
// Emit a signal after the process has been started

packages/angular_devkit/build_angular/src/builders/ssr-dev-server/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { SpawnOptions, spawn } from 'node:child_process';
10+
import { platform } from 'node:os';
1011
import { AddressInfo, createConnection, createServer } from 'node:net';
1112
import { Observable, mergeMap, retryWhen, throwError, timer } from 'rxjs';
1213

@@ -29,7 +30,13 @@ export function spawnAsObservable(
2930
options: SpawnOptions = {},
3031
): Observable<{ stdout?: string; stderr?: string }> {
3132
return new Observable((obs) => {
32-
const proc = spawn(`${command} ${args.join(' ')}`, options);
33+
// FIXED (CWE-78): raw args.join + shell:true allows injection via
34+
// a crafted outputPath/serverScript value in angular.json.
35+
// Invoke cmd.exe directly on Windows (shell:false) — no escaping needed.
36+
const isWin32 = platform() === 'win32';
37+
const proc = isWin32
38+
? spawn('cmd.exe', ['/d', '/s', '/c', command, ...args], { ...options, shell: false })
39+
: spawn(command, args, { ...options, shell: false });
3340
if (proc.stdout) {
3441
proc.stdout.on('data', (data) => obs.next({ stdout: data.toString() }));
3542
}

packages/angular_devkit/schematics/tasks/package-manager/executor.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ import { SpawnOptions, spawn } from 'node:child_process';
1717
* Algorithm: https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/
1818
*/
1919
function escapeArgForWindowsShell(arg: string): string {
20-
// Fast path: only safe characters present
21-
if (arg === '' || /^[a-zA-Z0-9_\-./\\:@]+$/.test(arg)) {
20+
// FIX A: empty string must produce a quoted empty token.
21+
// Returning bare '' causes cmd.exe to drop the arg silently,
22+
// shifting all subsequent args by one (argument confusion).
23+
if (!arg) return '""';
24+
// Fast path: only shell-safe chars, no quoting needed
25+
if (/^[a-zA-Z0-9_\-./\\:@]+$/.test(arg)) {
2226
return arg;
2327
}
28+
// FIX B: escape % BEFORE wrapping in double-quotes.
29+
// cmd.exe expands %VAR% before evaluating quote context, so
30+
// %COMSPEC% inside "..." still expands (BatBadBut / CVE-2024-3566).
31+
// %% disables expansion and becomes a literal percent sign.
32+
arg = arg.replace(/%/g, '%%');
2433
let result = '"';
2534
let slashes = 0;
2635
for (const char of arg) {

packages/schematics/angular/workspace/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ export default function (options: WorkspaceOptions): Rule {
2525
const packageManager = options.packageManager;
2626
let packageManagerWithVersion: string | undefined;
2727

28+
const ALLOWED_PKG_MANAGERS = new Set(['npm', 'yarn', 'pnpm', 'bun', 'cnpm']);
2829
if (packageManager) {
30+
// FIXED (CWE-78): packageManager is user-supplied with no runtime enum
31+
// validation (SCAN C = zero hits). Enforce an allowlist before execSync.
32+
if (!ALLOWED_PKG_MANAGERS.has(packageManager)) {
33+
throw new Error(
34+
`Invalid packageManager: "${packageManager}". Allowed: npm, yarn, pnpm, bun, cnpm`,
35+
);
36+
}
2937
let packageManagerVersion: string | undefined;
3038
try {
3139
packageManagerVersion = execSync(`${packageManager} --version`, {

0 commit comments

Comments
 (0)