Run the program directly with its arguments as a list - execFileSync(tool, ['--', name]) - so no shell parses them, and put '--' before any user operand so a leading dash cannot pose as an option. Never build a string for child_process.exec/execSync. Choose a format from an allowlist, never from free text.
- Find every child_process.exec, execSync and spawn/execFile called with { shell: true } whose command is built from req.query, req.params or req.body.
- Replace it with execFile/execFileSync or spawn (no shell), passing the program and an array of arguments: execFileSync(tool, ['--', name]).
- Put a literal '--' in the array before the first user-controlled operand, so a value beginning with '-' is treated as an operand and not as an option.
- For any argument that selects the command, a subcommand, a flag or an enumerated option value, map the caller's choice through an allowlist (a Map) and answer 400 for anything else.
- Check each query-string value is a single string before using it; Express 5's default 'simple' parser returns an array when a key repeats.
- Add regression tests with this card's payloads (separators, substitution, newline, and a leading-dash option) that assert a marker file was never created, next to functional tests for names with spaces, apostrophes and quotes.
- Log the tool's stderr on the server and return a generic 500; its error text helps an attacker tune a payload.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function greet(tool, name) {
// execSync runs the string through /bin/sh -c: every shell metacharacter in
// `name` is live, and `; | && $( ) backticks` and newlines all start new work.
return execSync(`${tool} ${name}`, { encoding: 'utf8' });
}With IMPL=vulnerable, 16 of 16 tests fail (10 exploit tests).
The fix
export function greet(tool, name) {
// execFileSync runs the program directly - no shell parses the arguments - and
// "--" means everything after it is an operand, so a name that starts with a
// dash cannot pose as an option. Metacharacters are just characters here.
return execFileSync(tool, ['--', name], { encoding: 'utf8' });
}With IMPL=fixed, 16 of 16 tests pass.
The fix: format allowlist
// The format picks part of the command itself, which a value can never do
// safely, so map the caller's choice onto a fixed flag value from an allowlist.
const FORMAT_FLAGS = new Map([
['text', 'text'],
['json', 'json'],
]);
export function report(tool, name, format) {
const flag = FORMAT_FLAGS.get(format);
if (flag === undefined) {
throw new InputRejected('unsupported format');
}
return execFileSync(tool, ['--format', flag, '--', name], { encoding: 'utf8' });
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
// The "obvious" shell separators. Note what is missing: $ ( ) and backticks
// (command substitution), and the leading dash of an injected option.
const BLOCKED = /[;|&<>\n]/;
function shellQuote(value) {
if (BLOCKED.test(value)) throw new InputRejected('illegal character in input');
return `"${value}"`;
}
export function greet(tool, name) {
return execSync(`${tool} ${shellQuote(name)}`, { encoding: 'utf8' });
}With IMPL=wrong, 7 of 16 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// OS command injection: a shell metacharacter in the name must stay data.
for (const [label, payload] of [
['a semicolon does not start a second command', 'Alice; touch MARKER'],
['a pipe does not start a second command', 'Alice | touch MARKER'],
['an && chain does not start a second command', 'Alice && touch MARKER'],
['a $( ) substitution does not run', 'Alice$(touch MARKER)'],
['backticks do not run a substitution', 'Alice`touch MARKER`'],
['a newline does not start a second command', 'Alice\ntouch MARKER'],
]) {
test(label, async () => {
const { dir, marker } = freshMarker();
await assertNoMarker('/greeting', { name: payload.replace('MARKER', marker) }, marker, dir);
});
}
// Argument injection: a name that starts with a dash must not pose as an option.
test('a leading --output= cannot make the tool write a file', async () => {
const { dir, marker } = freshMarker();
await assertNoMarker('/greeting', { name: `--output=${marker}` }, marker, dir);
});
test('a leading -o cannot make the tool write a file', async () => {
const { dir, marker } = freshMarker();
await assertNoMarker('/greeting', { name: `-o${marker}` }, marker, dir);
});
// The allowlisted format cannot smuggle in an extra option...
test('an option injected through the format is rejected', async () => {
const { dir, marker } = freshMarker();
await assertNoMarker('/report', { name: 'Alice Martin', format: `text --output=${marker}` }, marker, dir);
});
// ...and the operand after "--" cannot either, even with a valid format.
test('a leading-dash name is an operand, not an option', async () => {
const { dir, marker } = freshMarker();
await assertNoMarker('/report', { name: `--output=${marker}`, format: 'text' }, marker, dir);
});
});- spawn/execFile with { shell: true } re-introduces the shell and re-opens the hole; the safe default is shell: false (the default).
- execFile still runs exactly one program, but without '--' an operand that starts with '-' is parsed as an option. Argument injection (CWE-88) needs no shell at all.
- A template literal is string building, not escaping. child_process.exec has no parameter binding; the array form of execFile/spawn is the binding.
- Passing the whole command as a single spawn argument with shell: true is the same bug wearing an array.
- node:child_process has no built-in shell-quoting helper, and hand-quoting is exactly the mistake in impl/wrong.js; reach for execFile instead of a quoting library.
- A number or boolean from req.query coerced into the args array becomes '[object Object]' or throws; validate and convert to string deliberately.
- Allowlist by value, not by regex on the raw input: a blocklist of shell metacharacters still lets --output= and -o through, because a dash is not a metacharacter.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
- Against your own endpoint, send name=Alice;touch /tmp/pwned and name=--output=/tmp/pwned and confirm /tmp/pwned is never created.
- Do not rely on Semgrep's detect-child-process rule here: it matches the unprefixed 'child_process' module and found nothing on this card's 'node:child_process' import, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.lang.security.detect-child-process.detect-child-process | No findings on this card. The rule is a file-local taint audit whose sink patterns match require('child_process') and default/namespace imports of the unprefixed 'child_process' module; this card imports a named binding from 'node:child_process' (import { execSync } from 'node:child_process'), which the rule does not match. A probe with import { execSync } from 'child_process' produced findings - but on both impl/fixed.js and impl/vulnerable.js, because the rule flags any child_process call reachable from a parameter and does not distinguish execFile (a list) from exec (a shell). So it is not a control for this card; the regression tests are.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/shell-command-injection-from-environment | Not executed. The JavaScript model (semmle/javascript/frameworks/ChildProcess.qll) treats child_process.exec/execSync with a non-constant command as a shell-command sink and MapGet/req.query handlers as remote sources; a data-access split across app.js and impl/*.js needs the full-program build to connect source to sink.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/second-order-command-line-injection | Not executed. This is the query that targets argument injection (an argument that begins with '-'); recorded here because it is the CodeQL check that corresponds to the CWE-88 half of this card.Checked: github/codeql main at b5b8165; not executed |
| ESLint (security plugin) | security/detect-child-processdetect-child-process | Same syntactic check as the Semgrep rule: warns on child_process with a non-literal argument. Listed, not run, because this card has no ESLint toolchain.Checked: Rule documented on eslint-plugin-security; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 (node:child_process is built in) |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| node:child_process (built in) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the command-injection/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs command-injection/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- express 5.3.0
- shell /bin/sh (dash-compatible POSIX shell)
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 16 of 16 tests fail (10 exploit tests); wrong: 7 of 16 tests fail (5 exploit tests)
- Dependency freshness
- express 5.3.0 was published 2026-10-09 (one day before verification); taken because it is a security release (qs, body-parser and proxy-addr updates) and the prior 5.2.1 carries those advisories.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP OS Command Injection Defense Cheat Sheet
- Node.js v26 docs: child_process.execFile and the shell option
- Node.js v26 docs: child_process.spawn (arguments as a list)
- Express: Migrating to Express 5 (req.query and the default query parser)
- CWE-78: OS Command Injection
- CWE-88: Argument Injection
- CAPEC-88: OS Command Injection
- CAPEC-6: Argument Injection
Attack patterns: