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 = '';