5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / ANALYSIS.md MD
# 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**.