diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 893393970907..f4cb855de48b 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -20,6 +20,36 @@ 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. + * 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 { + 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'); +} + /** * An abstraction layer for side-effectful operations. */ @@ -130,7 +160,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 +168,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())); 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.');