-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"n8n's chat WebSocket handler accepts a connection on `/chat`, parses three query parameters, and calls a function literally named `checkIfExecutionExists`. If the execution row is present in the database, the connection stays open and starts streaming the workflow's pending prompt down the wire. That is the entire authorization check on n8n's public chat endpoint.\n\nThe function name is honest. The call site treats its boolean return as the authorization decision because nothing else was ever wired in: workflows with `isPublic=true` are exempt from n8n user authentication by design, and the chat URL was assumed to be the credential. The chat URL contains `executionId`, a database autoincrement column. The PoC scans values 1 through 200.\n\n## The check is its function name\n\nThe handler lives at `packages/cli/src/chat/chat-service.ts` in n8n 1.123.22 and 2.18.0, both affected. The relevant lines:\n\n```typescript\nasync startSession(req: ChatRequest) {\n const {\n ws,\n query: { sessionId, executionId, isPublic },\n } = req;\n\n if (!ws) {\n throw new UnexpectedError('WebSocket connection is missing');\n }\n\n if (!sessionId || !executionId) {\n ws.close(1008);\n return;\n }\n\n const execution = await this.executionManager.checkIfExecutionExists(executionId);\n\n if (!execution) {\n ws.send(`Execution with id \"${executionId}\" does not exist`);\n ws.close(1008);\n return;\n }\n\n ws.isAlive = true;\n // ... session bookkeeping, no further checks\n}\n```\n\nThree query parameters arrive on the URL: `sessionId`, `executionId`, `isPublic`. None of them are server-issued. `sessionId` is a UUID the JavaScript client generates and ships with the upgrade request; the server uses it as part of an internal Map key and never validates it against anything. `isPublic` is a boolean the caller asserts and the server accepts. `executionId` is the primary key in the `executions` table, where rows are inserted with a sequential integer.\n\nLook at the function the handler actually calls, in `chat-execution-manager.ts`:\n\n```typescript\nasync checkIfExecutionExists(executionId: string) {\n return await this.executionRepository.findSingleExecution(executionId);\n}\n```\n\nOne repository call. No `includeData`, no `unflattenData`. The function returns the bare row if present, undefined otherwise. The caller checks truthiness and proceeds. There is no other authorization decision in this code path.\n\n## What the connection is allowed to do\n\nOnce `startSession` accepts the socket, the connection is wired up to the execution's chat stream. A three-second polling loop reads the execution's status; when it is `waiting`, the server reads the pending prompt out of the execution's data blob and sends it down the socket. That prompt was intended for the legitimate user. The PoC captures it as plain text:\n\n```\n[+] WAITING execution found! exec_id=7\n Server said: 'n8n|continue'\n Server said: '{\"action\":\"sendMessage\",\"sessionId\":\"...\",\"chatInput\":\"Hello\"}'\n```\n\nThe connection can also write. The incoming-message handler decodes whatever the WebSocket peer sends, validates the JSON schema, and calls `resumeExecution`, which calls `runWorkflow`, which advances the execution past the wait node with whatever `chatInput` the peer supplied. The PoC sends:\n\n```json\n{\"action\":\"sendMessage\",\"sessionId\":\"\",\"chatInput\":\"[CVE-2026-42228] hijacked by PoC\"}\n```\n\nThe legitimate user, sitting at the Hosted Chat URL, sees their workflow resume with the attacker's message. They never typed it. The attacker's connection got there first, and from `startSession`'s point of view the attacker was the legitimate caller. The attacker named a row that exists. That was the test.\n\n## The patch\n\nReleased as n8n 1.123.32, 2.17.4, and 2.18.1 under GHSA-f77h-j2v7-g6mw. The diff against `chat-service.ts`:\n\n```diff\n async startSession(req: ChatRequest) {\n const {\n ws,\n- query: { sessionId, executionId, isPublic },\n+ query: { sessionId, executionId, isPublic, token },\n } = req;\n\n ...\n\n- const execution = await this.executionManager.checkIfExecutionExists(executionId);\n+ const execution = await this.executionManager.findExecution(executionId);\n\n if (!execution) {\n- ws.send(`Execution with id \"${executionId}\" does not exist`);\n+ ws.send('Connection rejected');\n ws.close(1008);\n return;\n }\n\n+ // Skip validation for old executions that lack a resumeToken (backwards compat).\n+ if (execution.data?.resumeToken) {\n+ const tokenBuf = Buffer.from(token ?? '');\n+ const storedBuf = Buffer.from(execution.data.resumeToken);\n+ if (!token || tokenBuf.length !== storedBuf.length || !timingSafeEqual(tokenBuf, storedBuf)) {\n+ // Same generic message as missing execution, do not leak which check failed\n+ ws.send('Connection rejected');\n+ ws.close(1008);\n+ return;\n+ }\n+ }\n```\n\nFour changes, three of them coupled.\n\nFirst, the URL now carries a `token` parameter. The legitimate frontend supplies it; an attacker scanning sequential IDs does not.\n\nSecond, the lookup switches from `checkIfExecutionExists` to `findExecution`. They are different functions on the same manager:\n\n```typescript\nasync findExecution(executionId: string) {\n return await this.executionRepository.findSingleExecution(executionId, {\n includeData: true,\n unflattenData: true,\n });\n}\n```\n\n`findExecution` loads the data blob; `checkIfExecutionExists` does not. The vulnerable code could not have checked a per-execution credential, because the function it called did not load the data field that would have stored one. The fix is not just adding a check; it is switching to a function that retrieves the data the check needs. The old design did not retrieve a credential at this point in the flow because there were no credentials to retrieve.\n\nThird, the data blob now carries a `resumeToken` field, populated when the execution enters the waiting state. The handler reads it, compares against the URL's `token` using `timingSafeEqual`, and rejects on mismatch.\n\nFourth, both rejection paths, missing execution and bad token, return the same generic string. That fourth change is a separate fix the CVE record does not name.\n\n## The error message was the oracle\n\nThe original handler responded to a bad ID with `Execution with id \"\" does not exist`. The PoC's scanner uses that exact substring as its enumeration signal:\n\n```python\nif \"does not exist\" in raw:\n result[\"status\"] = \"not_found\"\n break\n```\n\nA scanner walks IDs 1 through 200, reads the server's response per ID, and gets a clean per-ID classification: not_found, waiting, or error. The patch collapses two distinguishable failure modes (no such execution, bad token) into one identical reply. The patch comment names the intent: `Same generic message as missing execution, do not leak which check failed`.\n\nThe CVE record lists one issue: missing authorization. The patch ships two: missing authorization, and an information-disclosure oracle that made the missing authorization trivially exploitable at scale. Without the oracle, the scanner in the PoC would have to guess `executionId` against a target that no longer tells it whether the guess was close. With the oracle, the scanner enumerates the entire keyspace one HTTP request at a time.\n\n## Five independent reporters\n\nGHSA-f77h-j2v7-g6mw credits five reporters: 34selen, Aikido-Security, JorianWoltjer, reindaelman, grumpinout1. Aikido-Security is a commercial scanner vendor. JorianWoltjer is a named researcher with public writeups. The other three are individual handles. Five independent reports for one bug means the surface was being examined by multiple parties at the same time. The chat handler is short, the function name is the bug, and anyone reading `chat-service.ts` finds it inside fifteen lines.\n\n## Existence Is Authorization\n\nThis is the shape: a server handler is given a caller-supplied identifier, checks that the named resource exists, and treats the existence check as the authorization decision. The identifier is enumerable; the resource carries no per-call credential; the existence query and the authorization query are the same call. Anyone who can guess or enumerate the identifier becomes the authorized caller.\n\nn8n's case is a clean instance. The handler asks `checkIfExecutionExists`, which is honest about what it does, and the surrounding code assumes existence implies anything else. The pattern is broader than IDOR because there is no second user involved: there is no concept of an authenticated caller at all on the public chat endpoint. The bug is that \"the row is here\" was treated as the answer to a different question.\n\nThe architectural sibling is [`middleware-outside-the-hijacker`](/patterns/middleware-outside-the-hijacker), the WebSocket-upgrade variant of \"auth ran on the request, never on the streams.\" n8n's `chat-server.ts` registers the upgrade handler directly on the raw HTTP server:\n\n```typescript\nsetup(server: HttpServer, app: Application) {\n server.on('upgrade', (req: ChatRequest, socket, head) => {\n const parsedUrl = parseUrl(req.url ?? '');\n if (parsedUrl.pathname?.startsWith('/chat')) {\n this.wsServer.handleUpgrade(req, socket, head, (ws) => {\n this.attachToApp(req, ws, app as ExpressApplication);\n });\n }\n });\n\n app.use('/chat', async (req: ChatRequest) => {\n await this.chatService.startSession(req);\n });\n}\n```\n\nThe upgrade handler fires before Express middleware can run on it. The socket is hijacked, then `attachToApp` calls `app.handle(req, res)` to re-enter Express, which routes to `startSession`. The canonical Go-stdlib instance of this shape was [AdGuard Home, which authenticated the HTTP/1.1 upgrade and lost the HTTP/2 streams that followed](/posts/adguard-home-auth-outside-h2c). n8n's case is the variant where no middleware ran in the first place, because public chat is intentionally exempt from user authentication. The architectural cost is the same in both directions: the natural place to gate the connection (request-scoped middleware) is the wrong place. The only honest place for a per-execution check is inside the inner handler, which is where the patch put it.\n\n## Close\n\nThe patch closes this CVE. It does not close the design that made the CVE necessary: a public WebSocket whose only gate is whether the caller can name a row in the `executions` table. The fix added a token because the architecture had nowhere else to put one. The next chat-shaped endpoint n8n ships will land on the same surface, with the same need for the same kind of token, and whoever writes that endpoint will have to remember.\n\nPoC: [rudSarkar/CVE-2026-42228](https://github.com/rudSarkar/CVE-2026-42228)","closing_line":"The patch added the credential the URL was supposed to have. The architecture that made the URL the credential is still shipping.","hook_md":"n8n's chat WebSocket handler accepts a connection on `/chat`, parses three query parameters, and calls a function literally named `checkIfExecutionExists`. If the execution row is present in the database, the connection stays open and starts streaming the workflow's pending prompt down the wire. That is the entire authorization check on n8n's public chat endpoint.\n\nThe function name is honest. The call site treats its boolean return as the authorization decision because nothing else was ever wired in: workflows with `isPublic=true` are exempt from n8n user authentication by design, and the chat URL was assumed to be the credential. The chat URL contains `executionId`, a database autoincrement column. The PoC scans values 1 through 200.","post_id":264,"slug":"n8n-chat-existence-was-the-gate","title":"CVE-2026-42228: n8n's Chat WebSocket Treated Existence As Authorization","type":"initial","unreadable_sentence":"The function name is honest. `checkIfExecutionExists` does what it says, and the call site treated its boolean return as the authorization decision because nothing else was ever wired in."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarQHpgAKCRDeZjl4jgkQ JsY3AP9LK5NdmbBTXoxBLc6CoFzq+mOCb+R6+kglMF2Mz6Kh4AEArDtomPPfE1+W 0Zf9HRiFOXnX8YQmn+bpg6NOJ6nVNwI= =G3Om -----END PGP SIGNATURE-----