Command injection
Aikido's Command injection in 2024 unpacked report is a good reminder that “just shell out for this one thing” remains a top source of RCE in Node services. The pattern is always the same: untrusted input ends up inside a string that gets handed to /bin/sh -c (or cmd.exe /c on Windows), and a metacharacter like ;, |, $(), or a stray & turns one command into many.
DaloyJS is an HTTP framework, not a shell, so the framework itself can't generally interpolate attacker input into a child process on your behalf. What it cando is (1) keep its own runtime free of the primitives that get abused, and (2) document the safe pattern so the handlers you write don't reintroduce the bug.
What Daloy already does for you
| Layer | What it blocks |
|---|---|
Zero child_process in src/** | A CI gate (verify-no-remote-exec.ts) refuses to merge any PR that imports node:child_process, node:vm, eval, new Function, or a remote dynamic import() inside the runtime source. The framework cannot accidentally shell out, and a compromised maintainer cannot quietly add an exec('curl ... | sh') at import time. |
| Strict per-route schemas (Zod) | Every route declares params, query, and body shapes. If you constrain a field with z.enum([...]), a tight regex, or z.string().uuid(), attacker shell metacharacters don't reach your handler in the first place — the request is rejected with 400 problem+json. |
| Body-size cap (1 MiB default) | Stops the “DoS-amplified injection” pattern where the attacker uploads a multi-MB payload of shell glue hoping something on the server pipes it into a process. |
CLI spawn uses fixed argv | When you run daloy dev, the framework spawns node / bun / deno with a hardcoded argv built by buildDevCommand()— never a shell string. The create-daloy scaffolder does the same for git init and <pm> install: the command and arguments are constants; only the working directory is derived from input, and cwd is not shell-parsed. |
None of that prevents your handler from shelling out unsafely. That is on you. The rest of this page is the pattern to follow when you do need to invoke an external program from a Daloy route.
The safe shape: execFile / spawn, no shell
Two rules cover ~95% of the real-world Node CVEs in the Aikido write-up:
- Use
execFileorspawnwith an array of arguments. Neverexec(`cmd ${userInput}`)with a template string. - Leave
shell: false(the default). If you setshell: true, every argument becomes shell-parsable and you have to escape metacharacters yourself — which is exactly the bug class you're trying to avoid.
Even if body.sourcePath were "foo.mp4; rm -rf /", the regex would reject it at the boundary; if you removed the regex, execFile would still pass the whole string as a single argv element to ffmpeg, which would simply fail to open a file by that name. No shell ever runs.
Anti-patterns to grep for
These are the patterns the Aikido write-up keeps finding in compromised packages. Wire them into a CI grep (or Semgrep / CodeQL) on your own repo and you'll catch most new command-injection bugs at PR time:
If you want to lock the framework-level guarantee into your own app, copy scripts/verify-no-remote-exec.ts and run it from pnpm test. It refuses any import of child_process / vm, any bare eval, new Function, or remote dynamic import(). If a handler genuinely needs to shell out, scope the allow-list to that one file rather than turning the gate off for the whole repo.
Windows footgun: BatBadBut (CVE-2024-27980)
Node.js 21.7.2+ ships the fix for CVE-2024-27980 (“BatBadBut”): on Windows, launching a .bat / .cmd file through spawn without shell: true used to be vulnerable to argv-to-cmd-line re-quoting injection. If you target older Node versions, either upgrade Node, or set shell: trueand validate every argument against a strict allow-list before you spawn. Daloy's engines field already requires a fixed Node version, so you inherit the patched runtime by default — but you're still responsible for argument validation if you opt into shell: true.
When you really do need a shell
Sometimes you genuinely need pipes, globs, or output redirection. The safe pattern is to write the script as a real file on disk (or check it in) and spawn it as an argument-only invocation:
The script lives in your repo, gets code-reviewed, and only the validated values cross the process boundary. The Node side never has to build a shell string.
Defense in depth
- Drop privileges. Run the Node process as a non-root user and constrain it with seccomp / Kubernetes Pod Security or a Docker
USERdirective. Even a successful command injection then runs as an unprivileged user with no write access outside/tmp. - Audit your runtime dependencies.Most real-world command-injection CVEs in 2024 were not in application code — they were in transitive npm packages that wrapped
child_process.exec(). Usepnpm auditon a schedule, and prefer the supply-chain hardened install path documented in Supply-chain security. - Reach for a library when possible.
sharpfor image processing,archiverfor zip files,node:fs/promisesfor copies — an in-process Node library never invokes a shell, so the entire bug class disappears.
Reporting
Found a command-injection-shaped weakness in Daloy itself (e.g. a CLI or scaffolder that interpolates user input into a spawn call, or a documented example that demonstrates an unsafe pattern)? Report it privately via github.com/daloyjs/daloy/security/advisories/new. Don't open a public issue.