← all articles

highSecurity Advisory

[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.

CWE-1321 · Prototype Pollution → CWE-287 → CWE-200 · CVSS 7.1 / High · n8n < 2.28.1 · 2.27.x < 2.27.4 · 1.x < 1.123.61 · CVE-2026-59206

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: createScopedMiddlewareuserHasScopes(req.user, …)hasGlobalScopegetAuthPrincipalScopes(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/credentials and /rest/workflows currently 500, because a serialiser recurses on the self-referential Object.prototype.user. A pollution shape that terminates each walk reaches those controllers and their secrets.
  • Mutation on licensed instances. PATCH /rest/users/:id/role already clears the scope check; on a licensed instance, escalation to owner lands directly.
  • Object.prototype.env is writable. community-packages/npm-utils.ts spawns npm with a bare { cwd } options object whose env inherits 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.