Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix OS command injection in TypeScript and Next.js

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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 exec/execSync in a route handler or server action. Choose a format from an allowlist, never from free text.

Stack
  • Language TypeScript
  • Runtime Node.js 26.11.1 (route handlers use the default Node.js runtime; node:child_process is not available on the Edge runtime)
  • Framework Next.js 16.4.0 (App Router route handlers)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 35 KB
On this page

Steps

  1. Find every child_process.exec, execSync and spawn/execFile with { shell: true } in route handlers, server actions and the modules they call whose command includes request data (searchParams, params, await request.json(), formData()).
  2. Replace it with execFile/execFileSync or spawn (no shell), passing the program and an array of arguments: execFileSync(tool, ['--', name]).
  3. 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.
  4. 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.
  5. Read query values with searchParams.getAll() and accept exactly one string; a server action's FormData values can also be File objects, so check typeof value === 'string'.
  6. Add regression tests that call the route handler with a Request carrying this card's payloads (separators, substitution, newline, and a leading-dash option) and assert a marker file was never created, next to functional tests for names with spaces, apostrophes and quotes.
  7. Log the tool's stderr on the server and return a generic 500; its error text helps an attacker tune a payload.

Vulnerable, fixed and wrong

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

Vulnerablelib/impl/vulnerable.tsTypeScript
export function greet(tool: string, name: string) {
  // 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

Fixedlib/impl/fixed.tsTypeScript
export function greet(tool: string, name: string) {
  // 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

Fixedlib/impl/fixed.tsTypeScript
// 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: string, name: string, format: string) {
  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

Wrong fixlib/impl/wrong.tsTypeScript
// 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: string) {
  if (BLOCKED.test(value)) throw new InputRejected('illegal character in input');
  return `"${value}"`;
}

export function greet(tool: string, name: string) {
  return execSync(`${tool} ${shellQuote(name)}`, { encoding: 'utf8' });
}

With IMPL=wrong, 7 of 16 tests fail (5 exploit tests).

The regression test

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.

Testtests/greet.test.tsTypeScript
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 expectNoMarker('/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 expectNoMarker('/greeting', { name: `--output=${marker}` }, marker, dir);
  });

  test('a leading -o cannot make the tool write a file', async () => {
    const { dir, marker } = freshMarker();
    await expectNoMarker('/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 expectNoMarker('/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 expectNoMarker('/report', { name: `--output=${marker}`, format: 'text' }, marker, dir);
  });
});

Pitfalls

  • Server actions are public POST endpoints: an action that runs exec with a form field is reachable by anyone who can send the request, whatever the page shows.
  • spawn/execFile with { shell: true } re-introduces the shell and re-opens the hole; the safe default is shell: false.
  • 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.
  • node:child_process has no built-in shell-quoting helper, and hand-quoting is exactly the mistake in lib/impl/wrong.ts; reach for execFile instead of a quoting library.
  • 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.
  • A route that spawns processes needs the Node.js runtime; do not set export const runtime = 'edge' on it, and keep the tool path in configuration rather than in the request.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs command-injection/typescript-nextjs does this for you).
  2. Against your own deployment, request /greeting?name=Alice%3Btouch%20%2Ftmp%2Fpwned and /greeting?name=--output%3D%2Ftmp%2Fpwned and confirm /tmp/pwned is never created.
  3. Do not rely on Semgrep here: its child-process rule does not match node:child_process, and with a plain import it flags the fixed code too, so the marker-file regression tests are the control.

Scanner rules

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.

Scanner rules for Fix OS command injection in TypeScript and Next.js
ToolRuleWhat it found here
Semgrepjavascript.lang.security.detect-child-process.detect-child-processNo findings on any implementation: the rule matches the unprefixed 'child_process' module, and this card imports named bindings from 'node:child_process'. The node-express card's probe with a plain import showed it flags execFileSync in the fixed code as well, so it is not a control either way.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/javascript.lang.security.detect-child-process.detect-child-process app lib
Semgrepp/default, p/typescript, p/security-audit, p/owasp-top-tenNo findings on any implementation.Checked: Semgrep 1.180.0 (CE); on app and lib
CodeQLjs/command-line-injectionNext.qll models request.url of a GET exported from app/**/route.ts as a remote source, and ChildProcess modelling treats execSync with a non-constant command as a shell sink. Here GET is the return value of a factory in lib/greet-handlers.ts, so whether a path is reported was not tested.Checked: github/codeql main at b5b8165; not executed
CodeQLjs/second-order-command-line-injectionNot executed. This is the query for argument injection (an argument that begins with '-'), the CWE-88 half of this card.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript
RuntimeNode.js 26.11.1 (route handlers use the default Node.js runtime; node:child_process is not available on the Edge runtime)
FrameworkNext.js 16.4.0 (App Router route handlers)
next16.4.0
react, react-dom19.3.0
node:child_process (built in)Node.js 26.11.1
vitest (test only)5.0.3
typescript (type check and next build only)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the command-injection/typescript-nextjsfolder.

Shell
npm ci
Shell
# 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 --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs command-injection/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 35 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • next 16.4.0
  • react 19.3.0
  • vitest 5.0.3 (Vite 8.3.4)
  • typescript 6.0.3
  • shell /bin/sh (POSIX shell)
  • semgrep 1.180.0
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)
Test results
fixed 16/16 pass; vulnerable 16/16 fail (10 exploit tests; the functional tests fail too because the shell splits names on spaces); wrong 7/16 fail (5 exploit tests: $( ), backticks and the three leading-dash options), offline after npm ci. greet.sh is byte-identical to the other stacks' copies.
Dependency freshness
next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (TypeScript 7 no longer exports the compiler API next build's type check loads). npm audit found 0 vulnerabilities. tsc --noEmit and next build pass. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

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

  1. OWASP OS Command Injection Defense Cheat Sheet
  2. Node.js v26 docs: child_process.execFile and the shell option
  3. Node.js v26 docs: child_process.spawn (arguments as a list)
  4. Next.js: Route Handlers (App Router)
  5. Next.js: Data Security guide (server actions are reachable by direct POST requests)
  6. CWE-78: OS Command Injection
  7. CWE-88: Argument Injection
  8. CAPEC-88: OS Command Injection
  9. CAPEC-6: Argument Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.