From 5ed1576a99e037fd4a236d49beb69e9d26a242d0 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Wed, 2 Sep 2026 18:43:17 +0000 Subject: [PATCH 1/3] fix(@angular/cli): quote registry args on Windows and reject shell metacharacters --- packages/angular/cli/src/commands/add/cli.ts | 16 +++- .../commands/add/registry-validation.spec.ts | 88 +++++++++++++++++++ .../angular/cli/src/package-managers/host.ts | 5 +- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/angular/cli/src/commands/add/registry-validation.spec.ts diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index bc95f33ff8e7..bebaed6d46eb 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -32,6 +32,18 @@ import { VERSION } from '../../utilities/version'; class CommandError extends Error {} +export const SHELL_METACHARACTERS = /[&|;$`()]/; + +export function validateRegistry(registry: string): void { + if (!URL.canParse(registry)) { + throw new CommandModuleError('Option --registry must be a valid URL.'); + } + + if (SHELL_METACHARACTERS.test(registry)) { + throw new CommandModuleError('Option --registry contains invalid characters.'); + } +} + interface AddCommandArgs extends SchematicsCommandArgs { collection: string; verbose?: boolean; @@ -132,7 +144,9 @@ export default class AddCommandModule return true; } - if (typeof registry === 'string' && URL.canParse(registry)) { + if (typeof registry === 'string') { + validateRegistry(registry); + return true; } diff --git a/packages/angular/cli/src/commands/add/registry-validation.spec.ts b/packages/angular/cli/src/commands/add/registry-validation.spec.ts new file mode 100644 index 000000000000..65c873ba3627 --- /dev/null +++ b/packages/angular/cli/src/commands/add/registry-validation.spec.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SHELL_METACHARACTERS, validateRegistry } from './cli'; + +describe('registry validation', () => { + describe('SHELL_METACHARACTERS', () => { + it('should match shell metacharacters', () => { + expect(SHELL_METACHARACTERS.test('&')).toBe(true); + expect(SHELL_METACHARACTERS.test('|')).toBe(true); + expect(SHELL_METACHARACTERS.test(';')).toBe(true); + expect(SHELL_METACHARACTERS.test('$')).toBe(true); + expect(SHELL_METACHARACTERS.test('`')).toBe(true); + expect(SHELL_METACHARACTERS.test('(')).toBe(true); + expect(SHELL_METACHARACTERS.test(')')).toBe(true); + }); + + it('should not match safe URL characters', () => { + expect(SHELL_METACHARACTERS.test('https://registry.example.com')).toBe(false); + expect(SHELL_METACHARACTERS.test('http://registry.example.com/path')).toBe(false); + expect(SHELL_METACHARACTERS.test('https://registry.example.com:8080')).toBe(false); + }); + }); + + describe('validateRegistry', () => { + it('should reject URLs with shell metacharacters', () => { + expect(() => validateRegistry('https://example.com&cmd')).toThrow( + 'Option --registry contains invalid characters.', + ); + expect(() => validateRegistry('https://example.com|cmd')).toThrow( + 'Option --registry contains invalid characters.', + ); + expect(() => validateRegistry('https://example.com;cmd')).toThrow( + 'Option --registry contains invalid characters.', + ); + expect(() => validateRegistry('https://example.com$cmd')).toThrow( + 'Option --registry contains invalid characters.', + ); + expect(() => validateRegistry('https://example.com`cmd`')).toThrow( + 'Option --registry contains invalid characters.', + ); + expect(() => validateRegistry('https://example.com(cmd)')).toThrow( + 'Option --registry contains invalid characters.', + ); + }); + + it('should accept valid URLs', () => { + expect(() => validateRegistry('https://registry.example.com')).not.toThrow(); + expect(() => validateRegistry('http://registry.example.com:8080')).not.toThrow(); + expect(() => validateRegistry('https://registry.example.com/path')).not.toThrow(); + }); + + it('should reject invalid URLs', () => { + expect(() => validateRegistry('not-a-url')).toThrow( + 'Option --registry must be a valid URL.', + ); + }); + }); + + describe('Windows shell quoting', () => { + it('should wrap args in double quotes', () => { + const command = 'npm'; + const args = ['--registry', 'https://registry.example.com']; + const result = `${command} ${args + .map((a) => `"${String(a).replace(/"/g, '\\"')}"`) + .join(' ')}`; + expect(result).toBe( + 'npm "--registry" "https://registry.example.com"', + ); + }); + + it('should escape inner double quotes', () => { + const command = 'npm'; + const args = ['--registry', 'https://example.com?key="value"']; + const result = `${command} ${args + .map((a) => `"${String(a).replace(/"/g, '\\"')}"`) + .join(' ')}`; + expect(result).toBe( + 'npm "--registry" "https://example.com?key=\\"value\\""', + ); + }); + }); +}); diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 90f426f9ab71..dcb11942eab0 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -158,7 +158,10 @@ export const NodeJS_HOST: Host = { env, } satisfies SpawnOptions; const childProcess = isWin32 - ? spawn(`${command} ${args.join(' ')}`, spawnOptions) + ? spawn( + `${command} ${args.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(' ')}`, + spawnOptions, + ) : spawn(command, args, spawnOptions); let stdout = ''; From 188f6d0d2df4d575470d6f2d9772c035332df29f Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Wed, 2 Sep 2026 18:52:47 +0000 Subject: [PATCH 2/3] fix(@angular/cli): use spawn with args array on Windows and remove manual quoting tests --- .../commands/add/registry-validation.spec.ts | 28 ++----------------- .../angular/cli/src/package-managers/host.ts | 7 +---- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/packages/angular/cli/src/commands/add/registry-validation.spec.ts b/packages/angular/cli/src/commands/add/registry-validation.spec.ts index 65c873ba3627..fd37e65a19c0 100644 --- a/packages/angular/cli/src/commands/add/registry-validation.spec.ts +++ b/packages/angular/cli/src/commands/add/registry-validation.spec.ts @@ -22,8 +22,8 @@ describe('registry validation', () => { it('should not match safe URL characters', () => { expect(SHELL_METACHARACTERS.test('https://registry.example.com')).toBe(false); - expect(SHELL_METACHARACTERS.test('http://registry.example.com/path')).toBe(false); - expect(SHELL_METACHARACTERS.test('https://registry.example.com:8080')).toBe(false); + expect(SHELL_METACHARACTERS.test('http://registry.example.com:8080')).toBe(false); + expect(SHELL_METACHARACTERS.test('https://registry.example.com/path')).toBe(false); }); }); @@ -61,28 +61,4 @@ describe('registry validation', () => { ); }); }); - - describe('Windows shell quoting', () => { - it('should wrap args in double quotes', () => { - const command = 'npm'; - const args = ['--registry', 'https://registry.example.com']; - const result = `${command} ${args - .map((a) => `"${String(a).replace(/"/g, '\\"')}"`) - .join(' ')}`; - expect(result).toBe( - 'npm "--registry" "https://registry.example.com"', - ); - }); - - it('should escape inner double quotes', () => { - const command = 'npm'; - const args = ['--registry', 'https://example.com?key="value"']; - const result = `${command} ${args - .map((a) => `"${String(a).replace(/"/g, '\\"')}"`) - .join(' ')}`; - expect(result).toBe( - 'npm "--registry" "https://example.com?key=\\"value\\""', - ); - }); - }); }); diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index dcb11942eab0..e0fccd3bf09d 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -157,12 +157,7 @@ export const NodeJS_HOST: Host = { cwd: options.cwd, env, } satisfies SpawnOptions; - const childProcess = isWin32 - ? spawn( - `${command} ${args.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(' ')}`, - spawnOptions, - ) - : spawn(command, args, spawnOptions); + const childProcess = spawn(command, args, spawnOptions); let stdout = ''; childProcess.stdout?.on('data', (data) => (stdout += data.toString())); From 70b673e08b06af964dec4977fa357d14aa2d6298 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Wed, 2 Sep 2026 18:57:45 +0000 Subject: [PATCH 3/3] fix(@angular/cli): expand shell metacharacter rejection and disable requiresQuoting --- packages/angular/cli/src/commands/add/cli.ts | 2 +- .../cli/src/commands/add/registry-validation.spec.ts | 11 ++++++++++- packages/angular/cli/src/package-managers/host.ts | 1 - 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index bebaed6d46eb..aba4eb07f865 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -32,7 +32,7 @@ import { VERSION } from '../../utilities/version'; class CommandError extends Error {} -export const SHELL_METACHARACTERS = /[&|;$`()]/; +export const SHELL_METACHARACTERS = /[&|;$`()<>'"\n\r]/; export function validateRegistry(registry: string): void { if (!URL.canParse(registry)) { diff --git a/packages/angular/cli/src/commands/add/registry-validation.spec.ts b/packages/angular/cli/src/commands/add/registry-validation.spec.ts index fd37e65a19c0..b2fb8315f8f5 100644 --- a/packages/angular/cli/src/commands/add/registry-validation.spec.ts +++ b/packages/angular/cli/src/commands/add/registry-validation.spec.ts @@ -15,9 +15,15 @@ describe('registry validation', () => { expect(SHELL_METACHARACTERS.test('|')).toBe(true); expect(SHELL_METACHARACTERS.test(';')).toBe(true); expect(SHELL_METACHARACTERS.test('$')).toBe(true); - expect(SHELL_METACHARACTERS.test('`')).toBe(true); + expect(SHELL_METACHARACTERS.test(String.fromCharCode(96))).toBe(true); expect(SHELL_METACHARACTERS.test('(')).toBe(true); expect(SHELL_METACHARACTERS.test(')')).toBe(true); + expect(SHELL_METACHARACTERS.test('<')).toBe(true); + expect(SHELL_METACHARACTERS.test('>')).toBe(true); + expect(SHELL_METACHARACTERS.test('"')).toBe(true); + expect(SHELL_METACHARACTERS.test("'")).toBe(true); + expect(SHELL_METACHARACTERS.test('\n')).toBe(true); + expect(SHELL_METACHARACTERS.test('\r')).toBe(true); }); it('should not match safe URL characters', () => { @@ -47,6 +53,9 @@ describe('registry validation', () => { expect(() => validateRegistry('https://example.com(cmd)')).toThrow( 'Option --registry contains invalid characters.', ); + expect(() => validateRegistry('https://example.com?q=">whoami')).toThrow( + 'Option --registry contains invalid characters.', + ); }); it('should accept valid URLs', () => { diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index e0fccd3bf09d..e382ed1efa46 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -107,7 +107,6 @@ export interface Host { */ export const NodeJS_HOST: Host = { stat, - requiresQuoting: platform() === 'win32', mkdir, readFile: (path: string) => readFile(path, { encoding: 'utf8' }), copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE),