Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/angular/cli/src/commands/add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ import { VERSION } from '../../utilities/version';

class CommandError extends Error {}

export const SHELL_METACHARACTERS = /[&|;$`()<>'"\n\r]/;

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;
Expand Down Expand Up @@ -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;
}

Expand Down
73 changes: 73 additions & 0 deletions packages/angular/cli/src/commands/add/registry-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @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(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);
});
Comment on lines +13 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test suite to verify that the newly added shell metacharacters (such as <, >, ", ', \n, and \r) are correctly matched by SHELL_METACHARACTERS.

Suggested change
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 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(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', () => {
expect(SHELL_METACHARACTERS.test('https://registry.example.com')).toBe(false);
expect(SHELL_METACHARACTERS.test('http://registry.example.com:8080')).toBe(false);
expect(SHELL_METACHARACTERS.test('https://registry.example.com/path')).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.',
);
expect(() => validateRegistry('https://example.com?q=">whoami')).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.',
);
});
});
});
5 changes: 1 addition & 4 deletions packages/angular/cli/src/package-managers/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -157,9 +156,7 @@ export const NodeJS_HOST: Host = {
cwd: options.cwd,
env,
} satisfies SpawnOptions;
const childProcess = isWin32
? spawn(`${command} ${args.join(' ')}`, spawnOptions)
: spawn(command, args, spawnOptions);
const childProcess = spawn(command, args, spawnOptions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since the spawning logic on Windows has been changed from manually joining arguments into a single string to passing them as an array (spawn(command, args, spawnOptions)), Node.js will now automatically handle the quoting of arguments on Windows.

Because of this, the manual quoting enabled by requiresQuoting: platform() === 'win32' (defined on line 110 of this file) is no longer necessary and will actually cause command execution to fail on Windows. When requiresQuoting is true, package specifiers are wrapped in double quotes (e.g., "@angular/core"), which Node.js will then escape again, passing literal quotes to the package manager and resulting in EINVALIDPACKAGENAME errors.

Please update requiresQuoting on line 110 to be false (or remove it entirely if no longer needed).


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