child_process: don't drop stdio and args on array pollution - #65544
child_process: don't drop stdio and args on array pollution#65544hammad-iftikhar wants to merge 1 commit into
Conversation
An index accessor installed on %Array.prototype% by userland code makes
`push()` and `unshift()` assign through the prototype chain, so the value
is swallowed and a hole is left behind. child_process built its stdio
descriptor list, its argument list and its environment pairs that way,
so `Object.defineProperty(Array.prototype, '2', { set() {} })` was enough
to drop the command passed to `exec()`, lose an environment entry, and
hand the C++ layer an `undefined` stdio descriptor - which used to abort
with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal` and more recently
threw `TypeError: Cannot read properties of undefined (reading 'type')`.
Add an `arrayAppend()` helper to `internal/util` that defines the element
instead of assigning it, and use it where a hole would reach the C++
layer or silently corrupt data.
Fixes: nodejs#56531
Userland can indeed define such a property, at which point all bets are off and something crashing is pretty much inevitable. It is not reasonable to desire that the Node.js environment will continue to function in this case, nor is it reasonable to add overhead to mitigate against it. |
|
Thanks! On "something crashing is pretty much inevitable"A crash isn't what actually happens in most of these cases, and that's my At indices 0-2, On precedentCore already asserts this exact robustness, including for index accessors on Object.defineProperty(Array.prototype, "-1", { get() { return this[this.length - 1]; } });and asserts the REPL keeps working rather than crashing. And So I read the existing policy as "core defends against this", and this PR as On overheadYou're right that the version I pushed isn't free. Measured on this branch,
The env loop is the only one with unbounded N, and 16 µs is ~1.6% of a That is avoidable. Pushing and repairing only when the prototype actually
+332 ns on a ~1 ms spawn is roughly 0.03%. I'm happy to switch the PR to that (These are all from one machine; the absolute numbers will move elsewhere, but If it's principleThat's your call and I won't relitigate it. In that case, would you prefer: (a) narrowing this to just the stdio descriptor list — the part that (b) closing this, and taking a docs patch to Either is fine by me. |
Problem
Userland can install an index accessor on
Array.prototype:Array.prototype.push()andArray.prototype.unshift()write their elementswith
Set, which walks the prototype chain. When the target index has aninherited accessor, the setter runs, the value is silently discarded, and only
lengthis updated leaving a hole behind.child_processbuilt three separate lists that way:stdioStringToArray(),getValidStdio(),ChildProcess.prototype.spawn();normalizeSpawnArguments(), viaunshift();envKeys/envPairs, plus theexec()stdout/stderr chunk buffers.So the reproduction from #56531 lost the command it was told to run and handed
the C++ layer an
undefinedstdio descriptor:On the version reported in the issue this aborted the process:
On current
mainthe abort is gone, but the bug is notParseStdioOptions()reads
.typeoff the hole and the call fails with an internal-looking error:The throw is not the worst case, though. Indices
0-2also makespawnSync('echo', ['ok'])runecho undefinedandexecSync()fail withEINVAL, and at index3nothing throws at all, the child is simply spawnedwith an environment variable missing. See the table below for the measured
behaviour at each index.
Fix
Add
arrayAppend()tointernal/util. It appends withObjectDefineProperty(array, array.length, …), which is a[[DefineOwnProperty]]and therefore never consults the prototype chain:It is used in
child_processat the places where a hole would either reach theC++ layer or silently corrupt data.
stdioStringToArray()now uses arrayliterals (element definition, not assignment), and the two
unshift()callsin
normalizeSpawnArguments()are replaced by building the arrayfront-to-back.
How to reproduce and verify
1. The issue's original reproduction.
2. Sweep every affected index. Indices
0-3are the interesting range:three default stdio descriptors, and
[shell, '-c', command]for the shellpath.
Before, on
main@54b4e372f39:execSync("echo ok")spawnSync("echo", ["ok"])exec("echo ok")THREW spawnSync /bin/sh EINVAL"undefined"THREW spawnSync … EINVALTHREW Cannot read properties of undefined (reading 'type')THREW spawnSync /bin/sh EINVAL"undefined"THREW spawnSync … EINVALTHREW Cannot read properties of undefined (reading 'type')THREW spawnSync /bin/sh EINVAL"undefined"THREW spawnSync … EINVALTHREW Cannot read properties of undefined (reading 'type')"ok\n""ok\n""abcundefined""ok\n"Index
3is worth calling out separately: nothing throws, nothing looks wrong,and the child just silently runs with
Dmissing from its environment.After:
execSync("echo ok")spawnSync("echo", ["ok"])exec("echo ok")"ok\n""ok\n""abcd""ok\n""ok\n""ok\n""abcd""ok\n""ok\n""ok\n""abcd""ok\n""ok\n""ok\n""abcd""ok\n"3. The regression test.
$ ./node test/parallel/test-child-process-array-prototype-index-accessor.jstest/parallel/test-child-process-array-prototype-index-accessor.jsinstallsthe accessor at each of indices
0-3in a child process, then checksexec(),execFileSync()andspawnSync()return the expected stdout andthat a four-entry
envround-trips intact. The pollution has to happen in asubprocess, it breaks the test runner itself otherwise.
Confirmed red before the change:
and green after.
Test results
Built
Releaseon macOS 15.5 / arm64 (Apple silicon).Fixes: #56531