Skip to content

Commit 8bb0c36

Browse files
authored
fix(ng-dev): prevent OS command injection in ChildProcess wrappers
The ChildProcess.spawn and ChildProcess.spawnSync wrappers previously used `shell: true`, which causes Node.js to internally concatenate the command + args array into a single string and pass it to `/bin/sh -c`.
1 parent dfb1782 commit 8bb0c36

4 files changed

Lines changed: 24 additions & 14 deletions

File tree

.github/local-actions/branch-manager/main.js

Lines changed: 5 additions & 5 deletions
Large diffs are not rendered by default.

ng-dev/release/publish/external-commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,12 @@ export abstract class ExternalCommands {
202202
}
203203

204204
try {
205-
// We must source nvm.sh so the shell recognizes the 'nvm' command since nvm is not a binary but a shell script.
205+
// We must source nvm.sh so the shell recognizes the 'nvm' command since nvm is not a binary
206+
// but a shell function. The dot (.) built-in and && operator require shell: true here.
206207
await ChildProcess.spawn('. ~/.nvm/nvm.sh && nvm install', [], {
207208
cwd: projectDir,
208209
mode: 'on-error',
210+
shell: true,
209211
});
210212

211213
if (!quiet) {

ng-dev/utils/child-process.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ export interface CommonCmdOpts {
3131

3232
/** Interface describing the options for spawning a process synchronously. */
3333
export interface SpawnSyncOptions
34-
extends CommonCmdOpts, Omit<_SpawnSyncOptions, 'shell' | 'stdio' | 'input'> {}
34+
extends CommonCmdOpts, Omit<_SpawnSyncOptions, 'stdio' | 'input'> {}
3535

3636
/** Interface describing the options for spawning a process. */
37-
export interface SpawnOptions extends CommonCmdOpts, Omit<_SpawnOptions, 'shell' | 'stdio'> {}
37+
export interface SpawnOptions extends CommonCmdOpts, Omit<_SpawnOptions, 'stdio'> {}
3838

3939
/** Interface describing the options for exec-ing a process. */
40-
export interface ExecOptions extends CommonCmdOpts, Omit<_ExecOptions, 'shell' | 'stdio'> {}
40+
export interface ExecOptions extends CommonCmdOpts, Omit<_ExecOptions, 'stdio'> {}
4141

4242
/** Interface describing the options for spawning an interactive process. */
43-
export interface SpawnInteractiveCommandOptions extends Omit<_SpawnOptions, 'shell' | 'stdio'> {}
43+
export interface SpawnInteractiveCommandOptions extends Omit<_SpawnOptions, 'stdio'> {}
4444

4545
/** Interface describing the result of a spawned process. */
4646
export interface SpawnResult {
@@ -71,7 +71,7 @@ export abstract class ChildProcess {
7171
return new Promise<void>((resolve, reject) => {
7272
const commandText = `${command} ${args.join(' ')}`;
7373
Log.debug(`Executing command: ${commandText}`);
74-
const childProcess = _spawn(command, args, {...options, shell: true, stdio: 'inherit'});
74+
const childProcess = _spawn(command, args, {...options, stdio: 'inherit'});
7575
// The `close` event is used because the process is guaranteed to have completed writing to
7676
// stdout and stderr, using the `exit` event can cause inconsistent information in stdout and
7777
// stderr due to a race condition around exiting.
@@ -85,6 +85,10 @@ export abstract class ChildProcess {
8585
* @returns The command's stdout and stderr.
8686
*/
8787
static spawnSync(command: string, args: string[], options: SpawnSyncOptions = {}): SpawnResult {
88+
// Default shell to false to prevent OS command injection: with shell: true, Node.js
89+
// internally joins command + args into a single string evaluated by /bin/sh, making
90+
// shell metacharacters in args exploitable. Callers that genuinely require shell
91+
// features (e.g. sourcing shell scripts) may explicitly pass shell: true.
8892
const commandText = `${command} ${args.join(' ')}`;
8993
const env = getEnvironmentForNonInteractiveCommand(options.env);
9094

@@ -95,7 +99,7 @@ export abstract class ChildProcess {
9599
signal,
96100
stdout,
97101
stderr,
98-
} = _spawnSync(command, args, {...options, env, encoding: 'utf8', shell: true, stdio: 'pipe'});
102+
} = _spawnSync(command, args, {...options, env, encoding: 'utf8', stdio: 'pipe'});
99103

100104
/** The status of the spawn result. */
101105
const status = statusFromExitCodeAndSignal(exitCode, signal);
@@ -116,13 +120,17 @@ export abstract class ChildProcess {
116120
* rejects on command failure.
117121
*/
118122
static spawn(command: string, args: string[], options: SpawnOptions = {}): Promise<SpawnResult> {
123+
// Default shell to false to prevent OS command injection: with shell: true, Node.js
124+
// internally joins command + args into a single string evaluated by /bin/sh, making
125+
// shell metacharacters in args exploitable. Callers that genuinely require shell
126+
// features (e.g. sourcing shell scripts) may explicitly pass shell: true.
119127
const commandText = `${command} ${args.join(' ')}`;
120128
const env = getEnvironmentForNonInteractiveCommand(options.env);
121129

122130
return processAsyncCmd(
123131
commandText,
124132
options,
125-
_spawn(command, args, {...options, env, shell: true, stdio: 'pipe'}),
133+
_spawn(command, args, {...options, env, stdio: 'pipe'}),
126134
);
127135
}
128136

ng-dev/utils/repo-directory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {ChildProcess} from './child-process.js';
1010

1111
/** Determines the repository base directory from the current working directory. */
1212
export function determineRepoBaseDirFromCwd() {
13-
const {stdout, stderr, status} = ChildProcess.spawnSync('git', ['rev-parse --show-toplevel']);
13+
const {stdout, stderr, status} = ChildProcess.spawnSync('git', ['rev-parse', '--show-toplevel']);
1414
if (status !== 0) {
1515
throw Error(
1616
`Unable to find the path to the base directory of the repository.\n` +

0 commit comments

Comments
 (0)