[CVE-2026-59206] Prototype Pollution to Unauthenticated User Enumeration in n8n
A two-level dynamic write in replaceInvalidCredentials pollutes Object.prototype from a default Member account, so req.user resolves for unauthenticated requests and every account email, role and MFA status leaks.
Between April and May 2026, n8n shipped four Critical prototype-pollution advisories in quick succession (GHSA-hqr4-h3xv-9m3r, GHSA-q5f4-99jv-pgg5, GHSA-wrwr-h859-xh2r, GHSA-c8xv-5998-g76h). All four were the same primitive: an attacker-controlled two-level dynamic write. Each fix closed one instance, not the shared root cause, so more of the same bug was probably still there. I went looking for it.
n8n is a widely deployed, self-hosted workflow-automation platform, and its quirk is that every node in a workflow can carry credentials. On every workflow create, update and import, the backend runs replaceInvalidCredentials() to normalise them. That is the busiest write path in the product, and I found it building lookup maps out of keys taken straight from the request body. That was the sink.
What I was hunting for
The cousin bugs all had the same shape: code doing map[key1][key2] = value where the keys come from the request. That is harmless until key1 is the string __proto__, because in JavaScript map['__proto__'] isn't a normal property, it is the object's prototype. So the assignment writes onto Object.prototype, and whatever you put there shows up on every object in the program. That is prototype pollution.
The only trick is getting __proto__ in as a real key. Written in source code, { __proto__: ... } sets an object's prototype instead of a property, so it never becomes a key you can loop over. But JSON.parse('{"__proto__": ...}') keeps __proto__ as an ordinary own property, so when the server parses an attacker's JSON body and iterates its keys, __proto__ is right there like any other. So I went looking for that write on a code path an attacker could reach.
The sink
packages/cli/src/workflow-helpers.ts does several of these two-level writes. The nastiest is the credentialsById line, because it doesn't store a string, it stores the attacker's entire object under an attacker-chosen key:
// packages/cli/src/workflow-helpers.ts (the line-347 variant)
credentialsById[nodeCredentialType][nodeCredentials.id] = nodeCredentials;
// ^ '__proto__' ^ attacker-chosen ^ fully attacker-controlled
With nodeCredentialType === '__proto__', credentialsById['__proto__'] resolves to Object.prototype, and the final assignment drops an arbitrary object onto the prototype under an arbitrary key. And because the normaliser runs on create, update and import, three separate controllers all funnel into this one reachable line.
From a Member write to a poisoned prototype
You don't need to be an admin. Authenticate as any user with workflow:create (the default Member role) and post a workflow whose only node carries a booby-trapped credentials map. The inner id becomes the key written onto Object.prototype; I set it to user, to plant Object.prototype.user:
{"name":"poc","nodes":[{"id":"1","name":"t","type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[0,0],
"credentials":{"__proto__":{"id":"user","email":"pwn@example.com",
"role":{"slug":"global:owner","scopes":[
{"slug":"user:list"},{"slug":"user:read"},
{"slug":"project:list"},{"slug":"project:read"}]}}}}],
"connections":{},"settings":{}}
The request comes back HTTP 400. That looks like a failure, but the validation error body echoes the polluted keys right back at you, and that is the confirmation: the write reached Object.prototype. The pollution is now process-wide and sticks until n8n restarts.
The unauthenticated bypass, and why it needs no cookie
The pollution is planted by a Member, and my first hope was that it would escalate that account's own privileges. That path is mostly a dead end on a default install: a licence check guards the one mutation that would help. The clean, universal impact turned out to be something else, and more serious. The polluted prototype lets a completely unauthenticated request pass the auth check, as long as it sends no cookie at all. That last condition is the counterintuitive part, and it falls straight out of how the auth middleware is written.
The request-pipeline auth middleware (packages/cli/src/auth/auth.service.ts) admits a request like this:
const token = req.cookies[AUTH_COOKIE_NAME];
if (token) { /* cookie path: TypeORM lookup, set req.user … */ }
if (req.user) next(); // polluted prototype ⇒ truthy ⇒ proceed
else if (shouldSkipAuth) next();
else res.status(401).json({ message: 'Unauthorized' });
Send a token and the if (token) branch runs a TypeORM existsBy lookup, which chokes on the polluted query options, so authenticating actually gives you worse results. Send nothing and that whole branch is skipped. Execution falls straight to if (req.user). user is not an own property of req, so the lookup walks the prototype chain to the Object.prototype.user I planted, finds it truthy, and calls next(). The empty-handed request is the one that sails through.
The type guard downstream code trusts seals it, because in walks the chain too:
// packages/@n8n/db/src/entities/types-db.ts
export function isAuthenticatedRequest(req): req is AuthenticatedRequest {
return 'user' in req && req.user !== null; // both clauses pass on a polluted prototype
}
And the scope gate reads my object: createScopedMiddleware → userHasScopes(req.user, …) → hasGlobalScope → getAuthPrincipalScopes(req.user) → user.role.scopes.map(s => s.slug). Since I authored user.role.scopes, the anonymous request carries exactly the global scopes I listed for it.
What actually falls out
On a default install, every endpoint whose controller doesn't deep-walk req.user runs with my forged principal. Measured unauthenticated, with no cookie:
| Endpoint | Status | Result |
|---|---|---|
GET /rest/users |
200 | full user list: id, name, email, role, mfaEnabled, lastActiveAt for every account |
GET /rest/projects |
200 | every project: ids, types, names |
PATCH /rest/users/:id/role |
403 | scope check passes; only the Enterprise-licence gate stops the role change |
POST /rest/api-keys |
403 | per-route scope gate holds, but authentication itself is already bypassed |
The 403s are the tell, not a wall: authentication is fully bypassed, and what's left are secondary gates (licence, per-route scope). The 200s are the prize, every account's email, role and MFA status handed to an anonymous client, which is exactly the reconnaissance you'd want before targeted phishing or credential stuffing.
How far it really reaches
Everything above is the conservative floor, the part I proved and stand behind. Three more extensions are structurally reachable from the same primitive; I flagged them for the maintainer and deliberately did not weaponise them:
- Read every credential and workflow.
/rest/credentialsand/rest/workflowscurrently500, because a serialiser recurses on the self-referentialObject.prototype.user. A pollution shape that terminates each walk reaches those controllers and their secrets. - Mutation on licensed instances.
PATCH /rest/users/:id/rolealready clears the scope check; on a licensed instance, escalation to owner lands directly. Object.prototype.envis writable.community-packages/npm-utils.tsspawnsnpmwith a bare{ cwd }options object whoseenvinherits from the prototype: an audit-worthy road toward command execution. I didn't build the PoC, so I'm not claiming it.
Fix
n8n fixed it in 2.28.1 (and 2.27.4 / 1.123.61 on the maintenance lines). The durable fix rejects dangerous keys right at the sink: refuse __proto__, constructor and prototype as nodeCredentialType via the existing isSafeObjectProperty helper, or build the accumulators with Object.create(null) (the fix the May round applied to HttpRequestV3). In the auth layer, as defence-in-depth: gate on Object.hasOwn(req, 'user') instead of req.user truthiness, and use Object.hasOwn in isAuthenticatedRequest instead of in, so a poisoned prototype can never stand in for a real session.