Editor's Note: This is part two of a three-part series on AI orchestration platforms. Part one covered Langflow, while part three will explore Activepieces. This research was originally presented at BlueHat IL 2026 in a talk titled "Exploiting AI Orchestration Zero-Days via PostgreSQL Internals."
n8n is one of the most popular AI orchestration platforms in use today. It is a different stack entirely than Langflow, which I examined in Part 1, in the sense that it is a TypeScript app with queue-backed workers, but the idea is the same. It enables building AI applications and automations in an afternoon by simple drag-and-drop components, which represent specific events, data transformations, integrations or AI models.

Mapping the attack surface
n8n is a NestJS-style TypeScript app. Routes are declared with decorators like @RestController, @Post, and @Get. The authentication model is centralized in a single middleware chain.
For an app shaped like n8n, there are two ways a route can end up unauthenticated. The first is the decorator system itself. The second is everything registered outside of it.
The authentication middleware is wired exactly once, in AbstractServer:

So a decorator route is unauthenticated only if it explicitly opts out with skipAuth: true. This is a good design decision by n8n. Grepping for that flag (and for the AuthlessRequest type those handlers use) and got a short, mostly boring list including /login, health probes, /setup, and others.
After investigating the authentication logic itself, as with Langflow, the core looked solid. n8n uses JWTs properly.
One thing was striking, however: a setup endpoint typed as AuthenticatedRequest even though it was declared skipAuth: true:

That type-versus-flag mismatch is exactly the shape of bug you want to find on a TypeScript app—-the type system is telling you one thing and the middleware chain is telling you another. In this case, the handler doesn't actually rely on req.user, so it was a mistake rather than a vulnerability. It's a pattern worth grepping for everywhere, however, nothing came out of it in this case.
The decorator routes were not the whole story, though. AbstractServer also registers a pile of routes directly with app.use(...) and app.all(...), separately from the controller wiring. These do not go through the skipAuth / createAuthMiddleware pipeline, so each has to either install its own authentication or be intentionally public. The set looks like this:

And, the one that turned out to matter:

That is the entire registration. No middleware, no authentication dependency, no checks. Just "start a chat session for whoever asks."
Unauthenticated hijack of live Chat sessions
The headline finding is that any user of n8n's Chat node, the components that allow for Human-in-the-Loop (HITL) agentic workflows holding live conversations with humans, was exposed to an unauthenticated attacker who could both eavesdrop on the conversation and inject messages into it. And because the identifiers are enumerable, every running HITL chat session on an instance could be discovered.
What the Chat node is
n8n's Chat node lets an agentic workflow author have a back-and-forth conversation with a user, typically as the user-facing side of an AI agent. The node sends a message, waits for a response, sends another, and so on. Underneath, "waiting for the user's next message" suspends the workflow execution. Once the user replies, the execution resumes from where it left off.
The transport for that back-and-forth is a WebSocket served at /chat. The frontend connects to:

executionId identifies the suspended workflow run; sessionId is the chat session inside it. Together they pick out which conversation the socket is attached to.
The bug
There are exactly three things wrong here, and they compound.
/chathas no authentication. As we saw above, the registration is literally app.use('/chat', ...). There is noskipAuthflag because it's outside the decorator system, and no manual authentication check inside the handler either.executionIdis an enumerable integer. n8n uses sequential integer execution IDs. Nothing about opening the WebSocket requires you to know a valid ID in advance. You can simply try them in a loop.sessionIdis whatever the client says it is. The server doesn't bind a session to a user account. Whoever first connects with a givensessionIdis that session, as far as the chat layer is concerned.
Putting these bugs together makes the attack straightforward: Enumerate execution IDs, find a chat session that is currently waiting for input, and attach to it.
Finding a live session
The enumeration needs a way to tell "execution N is currently waiting for chat input" apart from "execution N doesn't exist" or "execution N finished hours ago." That signal comes from a sibling route, /form-waiting/:executionId/:suffix, which is registered through the same app.all(...) block shown earlier. A request to that URL returns the literal string "waiting" when the execution is suspended waiting for input, and something else otherwise. Walk the integers, note the responses, and you have a working oracle for which executions are ripe right now. In addition, to wait for new sessions, you just have to monitor the ID that follows the highest one that exists.
/form-waiting is not broken on its own. It exists so a polling client can check whether a waiting form has completed. But from an attacker's standpoint it leaks workflow execution state to the unauthenticated network, which is exactly how an attacker would know which execution to attack and when.
Hijacking the conversation
Once you have a live executionId, opening the WebSocket trivially attaches to the session:

The socket is bi-directional and the wire protocol is documented in the code – JSON user-message frames shaped like {"sessionId": ..., "action": "sendMessage", "chatInput": ...}.
Depending on the flow on the other side, the attacker can now read agent output (including any tool results, summaries, or retrieved context the AI is about to send back to the real user), inject new user messages to steer the conversation, and replay prior turns the chat history surfaces to the new connection.
Let’s demonstrate this. In the following demo, you can see a legitimate user interacting with an AI agent through n8n UI, and at the bottom-half a simulation of the attack we described above. You’ll be able to see the attacker first enumerates over the IDs to find an active one, reads the chat messages sent over this session and even injects a message on its own and controlling the session
This vulnerability was fixed by the n8n team and was assigned CVE-2026-42228 (Severity: Moderate, 6.3).
Escaping the n8n Python sandbox
This research also entailed a short episode with the n8n Python sandbox. n8n allows users to input Python code to allow custom processing logic in projects, which is a useful feature.
In order to enable this safely, the user Python code is evaluated under a hardened interpreter. The hardening here is done in two complementary ways:
The code first goes through an analyzer that checks if there are forbidden patterns using static code analysis (see SecurityValidator). Here is a partial list of forbidden patterns:
1. Imports
2. Blocking of specific names (
eval, exec, compile, __import__, __globals__, __class__, __subclasses__,etc)The code is executed with Python’s exec with globals only containing the print function and a limited subset of __builtins__ (see full list here). Blocked names list includes:
1.
eval, exec, compile, open, getattr, object, type, globalsand many more
The idea is to allow users to define logic in Python while disallowing any access to sensitive resources. Its defenses are thorough, but protecting against arbitrary code execution while still allowing some Python code execution is extremely difficult.
Both defenses fall down if an attacker manages to make a variable point to the original unfiltered globals. Normally, getting the unfiltered globals should be easy—we still have the print function, so we could’ve just done print.__builtins__[“exec”] to retrieve the exec function that would allow us to execute arbitrary code.
However, accessing the __builtins__ member is blocked by the static analysis, either by using . (dot) or getattr. However, our research found a bypass using one Python feature the analyzer didn't model at the time structural pattern matching: match/case binds attributes by structural matching at the class-pattern level, and that AST node type – MatchClass – wasn't on the visitor's path at all.
How do we use it to access unfiltered __builtins__? Here is an example:

The builtins variable contains the unfiltered builtins.
The one prerequisite was a reference to object (see its use above in the case) to use as the class in the match pattern, and the object was on the blocked-names list.
The way around this is the way every Python sandbox in history gets reached around—an object is hanging off the type metadata of any concrete class:

list.__format__ is a built-in method, and __objclass__ on a built-in method returns the class the method is defined on, which for __format__ is object. With object in hand, the match-pattern bypass binds __builtins__ straight into a local variable:

The MatchClass node wasn't being visited, so the keyword attribute slipped past every other check. From there, fetching eval and exec out of builtins and using print.__globals__ as a real module globals dict to feed exec is straightforward.
The full payload, pasted into the Code node, returns the listing of the root directory:

After testing this on n8n Cloud, it worked.
I then went to write up the report, but re-tested beforehand and it had stopped working. The analyzer now has a visit_MatchClass method that walks each pattern's keyword attributes and flags any blocked name, closing exactly this hole.
Whether the team caught my testing or another researcher had already reported it, I never found out. But it is an interesting vulnerability and exploit, and I think there we can learn from it.
The takeaway is not "use a blocklist." A static analyzer is only as good as the set of AST node types it visits and only as good as the patterns it knows to identify. However, Python keeps adding new syntactic forms that bind or dereference attributes. match/case was one such feature; future language additions will keep producing similar gaps. Sandboxes built on AST analysis need to be re-audited every time the grammar grows. Filtering global and static analysis of Python code is never enough on its own. That’s why n8n supports (in addition to the above) running the code inside a separated task runner, and it should be enabled to achieve a reliable security boundary.
Disclosure timeline
- Feb 8, 2026 — Vulnerability reported to n8n team
- Apr 20, 2026 — Vulnerability fixed
Ori Lahav is a security researcher at Rubrik Zero Labs. The research in this post was conducted as part of Rubrik Zero Labs' work on the security of emerging AI infrastructure.