# 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**.