From 441c1d96dc0f7171f96d31e8340c482632038950 Mon Sep 17 00:00:00 2001 From: Venkatesan <68438061+VenkatKwest@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:13:03 +0530 Subject: [PATCH 1/3] fix(@angular-devkit/schematics): remove shell usage in git spawn to prevent command injection Git is a native executable on Windows and does not require shell: true. Switch to array-based spawn and separate the -m flag from the commit message to prevent command injection via crafted commit messages. --- .../angular_devkit/schematics/tasks/repo-init/executor.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/angular_devkit/schematics/tasks/repo-init/executor.ts b/packages/angular_devkit/schematics/tasks/repo-init/executor.ts index 97b2b12a3619..607e1bfc5cba 100644 --- a/packages/angular_devkit/schematics/tasks/repo-init/executor.ts +++ b/packages/angular_devkit/schematics/tasks/repo-init/executor.ts @@ -29,7 +29,6 @@ export default function ( const errorStream = ignoreErrorStream ? 'ignore' : process.stderr; const spawnOptions: SpawnOptions = { stdio: [process.stdin, outputStream, errorStream], - shell: true, cwd: path.join(rootDirectory, options.workingDirectory || ''), env: { ...process.env, @@ -41,7 +40,7 @@ export default function ( }; return new Promise((resolve, reject) => { - spawn(`git ${args.join(' ')}`, spawnOptions).on('close', (code: number) => { + spawn('git', args, spawnOptions).on('close', (code: number) => { if (code === 0) { resolve(); } else { @@ -82,7 +81,7 @@ export default function ( if (options.commit) { const message = options.message || 'initial commit'; - await execute(['commit', `-m "${message}"`]); + await execute(['commit', '-m', message]); } context.logger.info('Successfully initialized git.'); From 40ed9a6d1aac15987d171bd149f0f1da1b8339e6 Mon Sep 17 00:00:00 2001 From: Venkatesan <68438061+VenkatKwest@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:13:56 +0530 Subject: [PATCH 2/3] 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. --- .../angular/cli/src/package-managers/host.ts | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 893393970907..0fddf3b8caae 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -20,6 +20,42 @@ import { platform, tmpdir } from 'node:os'; import { join } from 'node:path'; import { PackageManagerError } from './error'; +// cmd.exe metacharacters that need ^ escaping. +// Reference: http://www.robvanderwoude.com/escapechars.php +const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; + +/** Escapes a command name for safe use in cmd.exe. */ +function escapeCommandForCmd(cmd: string): string { + return cmd.replace(metaCharsRegExp, '^$1'); +} + +/** + * Escapes an argument for safe use in cmd.exe. + * Based on the algorithm from cross-spawn (https://github.com/moxystudio/node-cross-spawn) + * and https://qntm.org/cmd + */ +function escapeArgForCmd(arg: string): string { + // Convert to string + arg = `${arg}`; + + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + + // Sequence of backslashes followed by the end of the string + // (which will become a double quote later): + // double up all the backslashes + arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); + + // Quote the whole thing + arg = `"${arg}"`; + + // Escape cmd.exe meta chars with ^ + arg = arg.replace(metaCharsRegExp, '^$1'); + + return arg; +} + /** * An abstraction layer for side-effectful operations. */ @@ -130,7 +166,6 @@ export const NodeJS_HOST: Host = { return new Promise((resolve, reject) => { const spawnOptions = { - shell: isWin32, stdio: options.stdio ?? 'pipe', signal, cwd: options.cwd, @@ -139,9 +174,27 @@ export const NodeJS_HOST: Host = { ...options.env, }, } satisfies SpawnOptions; - const childProcess = isWin32 - ? spawn(`${command} ${args.join(' ')}`, spawnOptions) - : spawn(command, args, spawnOptions); + + let childProcess; + if (isWin32) { + // On Windows, package managers (npm, yarn, pnpm) are .cmd scripts that + // require a shell to execute. Instead of using shell: true (which is + // vulnerable to command injection), we invoke cmd.exe directly with + // properly escaped arguments. + // This approach is based on cross-spawn: + // https://github.com/moxystudio/node-cross-spawn + const escapedCmd = escapeCommandForCmd(command); + const escapedArgs = args.map((a) => escapeArgForCmd(a)); + const shellCommand = [escapedCmd, ...escapedArgs].join(' '); + + childProcess = spawn( + process.env.comspec || 'cmd.exe', + ['/d', '/s', '/c', `"${shellCommand}"`], + { ...spawnOptions, windowsVerbatimArguments: true }, + ); + } else { + childProcess = spawn(command, args, spawnOptions); + } let stdout = ''; childProcess.stdout?.on('data', (data) => (stdout += data.toString())); From 1b9dcc8713c91710538543dcdceb16e04d32e56d Mon Sep 17 00:00:00 2001 From: Venkatesan <68438061+VenkatKwest@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:46:37 +0530 Subject: [PATCH 3/3] refactor(@angular/cli): address review feedback for escapeArgForCmd Remove redundant string conversion, add proper attribution with authoritative Microsoft documentation link, and refactor to avoid multiple re-assignments as suggested by reviewer. --- .../angular/cli/src/package-managers/host.ts | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 0fddf3b8caae..f4cb855de48b 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -31,29 +31,23 @@ function escapeCommandForCmd(cmd: string): string { /** * Escapes an argument for safe use in cmd.exe. - * Based on the algorithm from cross-spawn (https://github.com/moxystudio/node-cross-spawn) - * and https://qntm.org/cmd + * Adapted from cross-spawn's `lib/util/escape.js`: + * https://github.com/moxystudio/node-cross-spawn/blob/master/lib/util/escape.js + * + * Algorithm based on https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way */ function escapeArgForCmd(arg: string): string { - // Convert to string - arg = `${arg}`; - - // Sequence of backslashes followed by a double quote: - // double up all the backslashes and escape the double quote - arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); - - // Sequence of backslashes followed by the end of the string - // (which will become a double quote later): - // double up all the backslashes - arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); - - // Quote the whole thing - arg = `"${arg}"`; - - // Escape cmd.exe meta chars with ^ - arg = arg.replace(metaCharsRegExp, '^$1'); - - return arg; + const processed = arg + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"') + // Sequence of backslashes followed by the end of the string + // (which will become a double quote later): + // double up all the backslashes + .replace(/(?=(\\+?)?)\1$/, '$1$1'); + + // Quote the whole thing and escape cmd.exe meta chars with ^ + return `"${processed}"`.replace(metaCharsRegExp, '^$1'); } /**