DEP0190: passing args to a child process with shell: true
Verified on Windows 11, Node 24.20.0 · OpusMill
(Use `node --trace-deprecation ...` to show where the warning was created)
It was there to run npm, npx or a .bin shim on
Windows: run the JS file behind the shim with
process.execPath and no shell, or use
cross-spawn.
You need pipes or &&: pass one
command string and quote each argument yourself.
Neither: delete shell: true.
Not just a warning
With shell: true, Node joins the command and your
args with spaces and hands one string to cmd.exe or
/bin/sh to parse again. It became a runtime warning in
Node
24.0.0. Here echo-arg.js is one line,
console.log(JSON.stringify(process.argv.slice(2))):
const { spawnSync } = require("node:child_process");
const out = (r) => r.stdout.toString().trim();
// with shell: true
console.log(out(spawnSync("node", ["echo-arg.js", "two words"], { shell: true })));
console.log(out(spawnSync("node", ["echo-arg.js", "x&echo INJECTED"], { shell: true })));
// without a shell
console.log(out(spawnSync(process.execPath, ["echo-arg.js", "two words"])));
console.log(out(spawnSync(process.execPath, ["echo-arg.js", "x&echo INJECTED"])));
["two","words"] ["x"] INJECTED ["two words"] ["x&echo INJECTED"]
One argument became two, and & ran a second command.
Both exited 0. Two bad calls, one warning: Node warns
once per process, so fixing one call can reveal the next.
exec(), or one command string, does not warn. That
silences it; it fixes nothing.
Which fix to use
Why shell: true is there | Do this |
|---|---|
To run npm, npx or a
node_modules/.bin shim on Windows |
.cmd files
throw EINVAL without a
shell. Run the JS behind them, or cross-spawn.
Below. |
Pipes, &&, redirects, globbing |
One string, every argument quoted by you. Below. |
No reason; the target is an .exe |
Delete it. Paths with spaces stop failing too. |
npm and .bin shims, no shell
npm.cmd only starts a JS file. Inside an npm script,
process.env.npm_execpath is that file:
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const npmCli = process.env.npm_execpath ||
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
const r = spawnSync(process.execPath, [npmCli, "--version"], { encoding: "utf8" });
console.log(npmCli);
console.log(r.status, r.stdout.trim());
--- npm run which-npm --- C:\...\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js 0 11.14.1 --- node run-npm.js --- C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js 0 11.19.0
Mind the fallback: outside an npm script it runs the npm
bundled with Node (11.19.0 here), not the one on the
PATH (11.14.1). Not tested with yarn or pnpm. For npx, use
npx-cli.js beside it (echo-args is a test
shim that prints its arguments):
const npxCli = path.join(path.dirname(npmCli), "npx-cli.js");
const args = ["two words", 'say "hi"', "%USERNAME%", 'a"&echo INJECTED'];
const x = spawnSync(process.execPath, [npxCli, "echo-args", ...args], { encoding: "utf8" });
console.log(x.status, x.stdout.trim());
0 ["two words","say \"hi\"","%USERNAME%","a\"&echo INJECTED"]
Any other .bin shim: run the file in the package's
bin field. Load package.json by path
(require.resolve fails on packages with
exports); bin may be a string:
function binFile(pkg, name) {
const dir = path.join(__dirname, "node_modules", pkg);
const { bin } = require(path.join(dir, "package.json"));
return path.join(dir, typeof bin === "string" ? bin : bin[name]);
}
spawnSync(process.execPath, [binFile("echo-args-bin", "echo-args"), ...args]);
Or let cross-spawn (7.0.6) find the .cmd and
escape for it:
const spawn = require("cross-spawn");
const npm = spawn.sync("npm", ["--version"], { encoding: "utf8" });
console.log(npm.status, npm.stdout.trim());
const args = ["two words", 'say "hi"', "%USERNAME%", 'a"&echo INJECTED'];
const r = spawn.sync("node_modules/.bin/echo-args", args, { encoding: "utf8" });
console.log(r.status, r.stdout.trim());
0 11.14.1 0 ["two words","say \"hi\"","%USERNAME%","a\"&echo INJECTED"]
No warning, and it runs the npm on the PATH. binFile
passed the same four arguments intact for all three package shapes.
When you do need the shell
function shellArg(s) {
if (process.platform !== "win32") return "'" + s.replace(/'/g, "'\\''") + "'";
if (/[\r\n]/.test(s)) throw new Error("cmd.exe cannot pass a line break in an argument");
s = '"' + s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1") + '"';
return s.replace(/[()\][%!^"`<>&|;, *?]/g, "^$&");
}
const args = ["two words", 'say "hi"', "%USERNAME%", "x&echo INJECTED", "C:\\dir\\"];
const cmd = `node echo-arg.js ${args.map(shellArg).join(" ")} && echo second command ran`;
const r = spawnSync(cmd, { shell: true, encoding: "utf8" });
console.log(r.status);
console.log(r.stdout.trim());
0 ["two words","say \"hi\"","%USERNAME%","x&echo INJECTED","C:\\dir\\"] second command ran
Limits. Tested on cmd.exe with an .exe
target. It throws on a line break, because cmd.exe ends the command
there and silently drops the rest, && part
included, with exit 0. It is not safe for a
.cmd or .bat, which cmd.exe parses
a second time. Argument a"&echo INJECTED:
node.exe : ["a\"&echo INJECTED"] .cmd shim: ["a\""] INJECTED"
The /bin/sh branch was tested with Git for Windows'
sh only, not on Linux or macOS.
Finding the call
node --trace-deprecation adds a stack. Under npm scripts,
set NODE_OPTIONS=--trace-deprecation instead.
(node:24920) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
at normalizeSpawnArguments (node:child_process:661:15)
at spawnSync (node:child_process:887:8)
at Object.<anonymous> (C:\...\split.js:5:17)
at Module._compile (node:internal/modules/cjs/loader:1929:14)
...
node --throw-deprecation makes it a crash (exit code 1)
for CI. It is not a guard: the throw comes later, after the children
already ran:
["two","words"]
["x"]
INJECTED
["two words"]
["x&echo INJECTED"]
node:internal/process/warning:176
throw warning;
^
DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
at normalizeSpawnArguments (node:child_process:661:15)
...
code: 'DEP0190'
}
Node.js v24.20.0
Check your own project for the rest of this class of bug.
Paste your code or your package.json into
the browser checker — nothing is uploaded,
it runs on your machine — or run
npx github:Hackierz/winbreak over the whole repository.
The rule that finds this one is shell-true-args-array.
Other errors in the same family:
- The fix for spawn EINVAL on a .cmd
- The fix for 'C:\Program' is not recognized
- The fix for ENOENT on node_modules/.bin
- The fix for 'NODE_ENV' is not recognized
- The fix for 'rm' is not recognized
Background: I scanned the 600 most-downloaded
npm CLI packages — 17.4% have a package.json
script that cannot run on Windows.