n8n has Arbitrary Command Execution via File Write and Git Operations
Description
n8n is an open source workflow automation platform. Prior to versions 2.2.0 and 1.123.8, an authenticated user with permission to create or modify workflows could chain the Read/Write Files from Disk node with git operations to achieve remote code execution. By writing to specific configuration files and then triggering a git operation, the attacker could execute arbitrary shell commands on the n8n host. The issue has been fixed in n8n versions 2.2.0 and 1.123.8. Users should upgrade to one of these versions or later to remediate the vulnerability. If upgrading is not immediately possible, administrators should consider the following temporary mitigations. Limit workflow creation and editing permissions to fully trusted users only, and/or disable the Read/Write Files from Disk node by adding n8n-nodes-base.readWriteFile to the NODES_EXCLUDE environment variable. These workarounds do not fully remediate the risk and should only be used as short-term mitigation measures.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
n8nnpm | < 1.123.8 | 1.123.8 |
n8nnpm | >= 2.0.0, < 2.2.0 | 2.2.0 |
Affected products
1Patches
297365caf2539fix: Limit access to files based on regex pattern (#23528)
4 files changed · +80 −0
packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts+21 −0 modified@@ -1,4 +1,5 @@ import { isContainedWithin, safeJoinPath } from '@n8n/backend-common'; +import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { NodeOperationError } from 'n8n-workflow'; import type { FileSystemHelperFunctions, INode, ResolvedFilePath } from 'n8n-workflow'; @@ -45,6 +46,22 @@ async function resolvePath(path: PathLike): Promise<ResolvedFilePath> { } } +function isFilePatternBlocked(resolvedFilePath: ResolvedFilePath): boolean { + const { blockFilePatterns } = Container.get(SecurityConfig); + + return blockFilePatterns + .split(';') + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern) + .some((pattern) => { + try { + return new RegExp(pattern, 'mi').test(resolvedFilePath); + } catch { + return true; + } + }); +} + function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { const allowedPaths = getAllowedPaths(); const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; @@ -56,6 +73,10 @@ function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { return true; } + if (isFilePatternBlocked(resolvedFilePath)) { + return true; + } + if (allowedPaths.length) { return !allowedPaths.some((allowedPath) => isContainedWithin(allowedPath, resolvedFilePath)); }
packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts+50 −0 modified@@ -1,3 +1,4 @@ +import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { constants, createReadStream } from 'node:fs'; @@ -23,6 +24,9 @@ jest.mock('node:fs/promises'); const originalProcessEnv = { ...process.env }; let instanceSettings: InstanceSettings; +let securityConfig: SecurityConfig; +let originalBlockedFilePatterns: string; + beforeEach(() => { process.env = { ...originalProcessEnv }; @@ -33,6 +37,13 @@ beforeEach(() => { (fsRealpath as jest.Mock).mockImplementation((path: string) => path); instanceSettings = Container.get(InstanceSettings); + securityConfig = Container.get(SecurityConfig); + securityConfig.restrictFileAccessTo = ''; + originalBlockedFilePatterns = securityConfig.blockFilePatterns; +}); + +afterEach(() => { + securityConfig.blockFilePatterns = originalBlockedFilePatterns; }); describe('isFilePathBlocked', () => { @@ -182,6 +193,45 @@ describe('isFilePathBlocked', () => { (fsRealpath as jest.Mock).mockRejectedValueOnce(error); expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true); }); + + it.each(['.git', '/.git', '/tmp/.git', '/tmp/.git/config'])( + 'should per default block access to %s', + async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(true); + }, + ); + + it('should allow access when pattern matching is disabled', async () => { + securityConfig.blockFilePatterns = ''; + expect(isFilePathBlocked(await resolvePath('/tmp/.git'))).toBe(false); + }); + + it('should block all access when using invalid pattern', async () => { + securityConfig.blockFilePatterns = '('; + expect(isFilePathBlocked(await resolvePath('/tmp/xo'))).toBe(true); + }); + + describe('when multiple file patterns are configured', () => { + beforeEach(() => { + securityConfig.blockFilePatterns = 'hello; \\/there$; ^where'; + }); + + it.each([ + 'hello', + 'xhellox', + 'subpath/hello/', + '/there', + '/subpath/there', + 'where', + 'where-is/it', + ])('should block access to %s', async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(true); + }); + + it.each(['/there/is', '/where'])('should not block access to %s', async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(false); + }); + }); }); describe('getFileSystemHelperFunctions', () => {
packages/@n8n/config/src/configs/security.config.ts+8 −0 modified@@ -19,6 +19,14 @@ export class SecurityConfig { @Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES') blockFileAccessToN8nFiles: boolean = true; + /** + * Blocked file and folder regular expression patterns that `ReadWriteFile` and `ReadBinaryFiles` nodes cant access. Separate multiple patterns with with semicolon `;`. + * - `^(.*\/)*\.git(\/.*)*$` + * Set to empty to not block based on file patterns. + */ + @Env('N8N_BLOCK_FILE_PATTERNS') + blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$'; + /** * In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed. */
packages/@n8n/config/test/config.test.ts+1 −0 modified@@ -320,6 +320,7 @@ describe('GlobalConfig', () => { security: { restrictFileAccessTo: '', blockFileAccessToN8nFiles: true, + blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$', daysAbandonedWorkflow: 90, contentSecurityPolicy: '{}', contentSecurityPolicyReportOnly: false,
e22acaab3dcbfix: Limit access to files based on regex pattern (#23413)
4 files changed · +75 −0
packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts+20 −0 modified@@ -47,6 +47,22 @@ async function resolvePath(path: PathLike): Promise<ResolvedFilePath> { } } +function isFilePatternBlocked(resolvedFilePath: ResolvedFilePath): boolean { + const { blockFilePatterns } = Container.get(SecurityConfig); + + return blockFilePatterns + .split(';') + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern) + .some((pattern) => { + try { + return new RegExp(pattern, 'mi').test(resolvedFilePath); + } catch { + return true; + } + }); +} + function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { const allowedPaths = getAllowedPaths(); const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; @@ -58,6 +74,10 @@ function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { return true; } + if (isFilePatternBlocked(resolvedFilePath)) { + return true; + } + if (allowedPaths.length) { return !allowedPaths.some((allowedPath) => isContainedWithin(allowedPath, resolvedFilePath)); }
packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts+46 −0 modified@@ -24,6 +24,8 @@ const originalProcessEnv = { ...process.env }; let instanceSettings: InstanceSettings; let securityConfig: SecurityConfig; +let originalBlockedFilePatterns: string; + beforeEach(() => { process.env = { ...originalProcessEnv }; @@ -36,6 +38,11 @@ beforeEach(() => { instanceSettings = Container.get(InstanceSettings); securityConfig = Container.get(SecurityConfig); securityConfig.restrictFileAccessTo = ''; + originalBlockedFilePatterns = securityConfig.blockFilePatterns; +}); + +afterEach(() => { + securityConfig.blockFilePatterns = originalBlockedFilePatterns; }); describe('isFilePathBlocked', () => { @@ -185,6 +192,45 @@ describe('isFilePathBlocked', () => { (fsRealpath as jest.Mock).mockRejectedValueOnce(error); expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true); }); + + it.each(['.git', '/.git', '/tmp/.git', '/tmp/.git/config'])( + 'should per default block access to %s', + async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(true); + }, + ); + + it('should allow access when pattern matching is disabled', async () => { + securityConfig.blockFilePatterns = ''; + expect(isFilePathBlocked(await resolvePath('/tmp/.git'))).toBe(false); + }); + + it('should block all access when using invalid pattern', async () => { + securityConfig.blockFilePatterns = '('; + expect(isFilePathBlocked(await resolvePath('/tmp/xo'))).toBe(true); + }); + + describe('when multiple file patterns are configured', () => { + beforeEach(() => { + securityConfig.blockFilePatterns = 'hello; \\/there$; ^where'; + }); + + it.each([ + 'hello', + 'xhellox', + 'subpath/hello/', + '/there', + '/subpath/there', + 'where', + 'where-is/it', + ])('should block access to %s', async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(true); + }); + + it.each(['/there/is', '/where'])('should not block access to %s', async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(false); + }); + }); }); describe('getFileSystemHelperFunctions', () => {
packages/@n8n/config/src/configs/security.config.ts+8 −0 modified@@ -20,6 +20,14 @@ export class SecurityConfig { @Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES') blockFileAccessToN8nFiles: boolean = true; + /** + * Blocked file and folder regular expression patterns that `ReadWriteFile` and `ReadBinaryFiles` nodes cant access. Separate multiple patterns with with semicolon `;`. + * - `^(.*\/)*\.git(\/.*)*$` + * Set to empty to not block based on file patterns. + */ + @Env('N8N_BLOCK_FILE_PATTERNS') + blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$'; + /** * In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed. */
packages/@n8n/config/test/config.test.ts+1 −0 modified@@ -322,6 +322,7 @@ describe('GlobalConfig', () => { security: { restrictFileAccessTo: '~/.n8n-files', blockFileAccessToN8nFiles: true, + blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$', daysAbandonedWorkflow: 90, contentSecurityPolicy: '{}', contentSecurityPolicyReportOnly: false,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-x2mw-7j39-93xqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27498ghsaADVISORY
- github.com/n8n-io/n8n/commit/97365caf253978ba8e46d7bc53fa7ac3b6f67b32ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/commit/e22acaab3dcb2004e5fe0bf9ef2db975bde61866ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/releases/tag/n8n@1.123.8ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/releases/tag/n8n@2.2.0ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/security/advisories/GHSA-x2mw-7j39-93xqghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.