Fix potential OS command injection in async.js - #1945
Conversation
@microsoft-github-policy-service agree [company="{Beijing University Of Technology}"] |
|
W
@microsoft-github-policy-service agree [company="Beijing University Of Technology"] |
There was a problem hiding this comment.
🟡 Not ready to approve
As written, it breaks existing call sites that explicitly rely on shell: true and shell features (e.g. pipelines), and the new exec() parsing/option handling changes behavior in ways that will cause failures.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR aims to mitigate a potential OS command-injection vector in the build utilities by avoiding implicit shell execution in build/src/utils/async.js and moving exec() away from child_process.exec.
Changes:
- Switched the
exec()wrapper fromchild_process.exectochild_process.execFile. - Changed
spawn()/exec()option handling to disable shell execution by default. - Added command-string splitting to derive
file+argsforexecFile.
File summaries
| File | Description |
|---|---|
| build/src/utils/async.js | Updates process-execution wrappers to reduce shell usage (security hardening), with behavioral changes to spawn()/exec() defaults and exec() implementation. |
Review details
Comments suppressed due to low confidence (2)
build/src/utils/async.js:30
spawn()forcesopts.shell = false, overriding callers that explicitly pass{ shell: true }(for examplebuild/src/push.js:207and multiple uses inbuild/src/utils/image-content-extractor.js). This is a breaking change and will cause commands that depend on shell expansion/quoting to fail. Prefer a secure default (shell: false) while still honoring an explicit caller opt-in.
opts = Object.assign({}, opts || { stdio: 'inherit' });
opts.shell = false;
build/src/utils/async.js:87
exec()currently (1) forcesopts.shell = falseeven when the caller explicitly requests shell execution and (2) passes astdiodefault that can interfere withexecFilestdout/stderr capture. This breaks existing uses that rely onshell: truefor pipelines (e.g.docker export ... | docker import ...) and can also causeasyncUtils.exec()to return an empty string by default. Use a secure default (shell: false), dropstdiofor exec/execFile, and switch betweenexec()andexecFile()based on whether shell execution was explicitly requested.
opts = Object.assign({}, opts || { stdio: 'inherit' });
opts.shell = false;
const parts = command.trim().split(/\s+/);
const file = parts[0];
const args = parts.slice(1);
return new Promise((resolve, reject) => {
let result = '';
const proc = execFileCb(file, args, opts);
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| const spawnCb = require('child_process').spawn; | ||
| const execCb = require('child_process').exec; | ||
| const execFileCb = require('child_process').execFile; |
There was a problem hiding this comment.
exec is now imported alongside execFile.
spawn and exec now only default shell to false when the caller does not specify it (if (opts.shell === undefined) opts.shell = false). Callers that explicitly pass { shell: true } (e.g. build/src/push.js:215 for pipelines) are respected.
When shell: true is requested, exec uses child_process.exec as before. When shell: false (the default), it uses child_process.execFile.
| const parts = command.trim().split(/\s+/); | ||
| const file = parts[0]; | ||
| const args = parts.slice(1); |
There was a problem hiding this comment.
Changed exec from exec(command, opts) to exec(file, args, opts). Arguments are now passed directly to execFile as an array, eliminating the need for command.trim().split(/\s+/) entirely.
Paths with spaces (C:\Program Files...) are preserved because the caller passes the full path as file.
Arguments with embedded spaces or quotes are preserved because each argument is a separate array element.
When shell: true is explicitly requested, the argument array is joined back into a command string for exec.
|
@microsoft-github-policy-service agree
乌衣巷.
***@***.***
|
Command Injection Reproduction Notes for
async.jsSummary
Hello,
I am writing to report a potential OS Command Injection vulnerability in the following file:
build/src/utils/async.jsThe issue appears when user-controlled input is passed to the utility's
spawnandexecwrapper functions. Both functions are configured with{ shell: true }enabled by default. Under these conditions, specially crafted input strings containing shell metacharacters can escape the intended command context and execute arbitrary code on the host system. This poses a significant security risk as it allows for Remote Code Execution (RCE).Root Cause
The issue is caused by the default
shell: trueoption enforced in the following functions:Reproduction Material
A minimal reproduction script is provided in:
poc_async.jspoc_async.js
This Proof of Concept (PoC) is intended to demonstrate that external input can reach dangerous command execution logic and trigger unauthorized OS commands.
What the PoC Does
The PoC performs a minimal end-to-end trigger of the vulnerable code path:
child_process.execandchild_process.spawnto log intercepted commands before they execute.async.js.utils.exec()with a payload containing& calc #designed to chain an additional command.utils.spawn()with a similar payload, demonstrating that array-based arguments are still vulnerable whenshell: trueis active.In the provided example, the injected payload is crafted for Windows systems. Successful command execution will launch the Calculator application.
Example Payload
The PoC uses the following payload for the input parameters:
How to Run
Run from the project root:
Expected Output
When the PoC runs, you should see output similar to:
In the vulnerable version, after the requests are processed, the local machine will launch two instances of the Calculator application as a benign demonstration effect (one for
execand one forspawn).This shows that the attacker-controlled input can influence command execution behavior through both vulnerable execution paths.
Patch Explanation
This branch also includes a patched version of
async.jsintended to mitigate the command injection risk described above.What the patch changes
The patch introduces two key changes to prevent shell metacharacter interpretation:
Disables default shell execution: The default
shelloption is changed fromtruetofalsein bothspawnandexecwrappers. This prevents the OS shell from interpreting metacharacters in command arguments.Replaces
child_process.execwithchild_process.execFile: Theexecwrapper now parses the command string usingcommand.trim().split(/\s+/)into a binary path and arguments array, then callsexecFilewhich executes the binary directly without a shell.Validation introduced by the patch
The patched version makes the following changes:
spawn wrapper: Defaults to
shell: false(explicitly set withopts.shell = false). Arguments are passed literally to the target process without any shell interpretation.exec wrapper: Uses
command.trim().split(/\s+/)to split the command string into the executable file path and its arguments, then delegates tochild_process.execFile:Because
execFileexecutes the binary directly without invokingcmd.exeor/bin/sh, shell metacharacters such as&,|,;,$,>, and<are treated as literal argument values rather than command syntax. This prevents command injection regardless of what characters appear in user-supplied input.