n8n Arbitrary File Write on Remote Systems via SSH Node
Description
n8n is an open source workflow automation platform. Prior to versions 1.123.12 and 2.4.0, when workflows process uploaded files and transfer them to remote servers via the SSH node without validating their metadata the vulnerability can lead to files being written to unintended locations on those remote systems potentially leading to remote code execution on those systems. As a prerequisites an unauthenticated attacker needs knowledge of such workflows existing and the endpoints for file uploads need to be unauthenticated. This issue has been patched in versions 1.123.12 and 2.4.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
n8nnpm | >= 2.0.0, < 2.4.0 | 2.4.0 |
n8nnpm | < 1.123.12 | 1.123.12 |
Affected products
1Patches
2528ad6b982d0fix(core): Sanitize filenames for file operations (#24221)
6 files changed · +110 −4
packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts+3 −2 modified@@ -17,6 +17,7 @@ import { fileTypeFromMimeType, ApplicationError, UnexpectedError, + sanitizeFilename, } from 'n8n-workflow'; import path from 'path'; import type { Readable } from 'stream'; @@ -211,9 +212,9 @@ export async function copyBinaryFile( }; if (fileName) { - returnData.fileName = fileName; + returnData.fileName = sanitizeFilename(fileName); } else if (filePath) { - returnData.fileName = path.parse(filePath).base; + returnData.fileName = sanitizeFilename(filePath); } return await Container.get(BinaryDataService).copyBinaryFile(
packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts+10 −0 modified@@ -627,6 +627,16 @@ describe('copyBinaryFile', () => { filePath, ); }); + + it('should sanitize filenames', async () => { + await copyBinaryFile(workflowId, executionId, filePath, '../../../etc/passwd'); + + expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( + { type: 'execution', workflowId, executionId }, + expect.objectContaining({ fileName: 'passwd' }), + filePath, + ); + }); }); describe('prepareBinaryData', () => {
packages/nodes-base/nodes/Ssh/Ssh.node.ts+11 −2 modified@@ -9,7 +9,12 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { BINARY_ENCODING, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; +import { + BINARY_ENCODING, + NodeConnectionTypes, + NodeOperationError, + sanitizeFilename, +} from 'n8n-workflow'; import type { Config } from 'node-ssh'; import { NodeSSH } from 'node-ssh'; import type { Readable } from 'stream'; @@ -444,11 +449,15 @@ export class Ssh implements INodeType { try { await writeFile(binaryFile.path, uploadData); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const rawFileName = fileName || binaryData.fileName || ''; + const sanitizedFileName = sanitizeFilename(rawFileName); + await ssh.putFile( binaryFile.path, `${parameterPath}${ parameterPath.charAt(parameterPath.length - 1) === '/' ? '' : '/' - }${fileName || binaryData.fileName}`, + }${sanitizedFileName}`, ); returnItems.push({
packages/workflow/src/index.ts+1 −0 modified@@ -51,6 +51,7 @@ export { setSafeObjectProperty, isDomainAllowed, isCommunityPackageName, + sanitizeFilename, } from './utils'; export { isINodeProperties,
packages/workflow/src/utils.ts+34 −0 modified@@ -4,6 +4,7 @@ import type { Node as SyntaxNode, ExpressionStatement } from 'esprima-next'; import FormData from 'form-data'; import { jsonrepair } from 'jsonrepair'; import merge from 'lodash/merge'; +import path from 'path'; import { ALPHABET } from './constants'; import { ManualExecutionCancelledError } from './errors/execution-cancelled.error'; @@ -439,3 +440,36 @@ export function isCommunityPackageName(packageName: string): boolean { return !!nameMatch; } + +/** + * Extracts a safe filename from a path or filename string. + * + * Handles both Unix and Windows path separators, removing directory + * components and null bytes to return just the filename. + * + * @param fileName - The filename or path to sanitize + * @returns The extracted filename without path components + * + * @example + * sanitizeFilename('path/to/file.txt') // returns 'file.txt' + * sanitizeFilename('/tmp/upload/doc.pdf') // returns 'doc.pdf' + * sanitizeFilename('C:\\Users\\file.txt') // returns 'file.txt' + * sanitizeFilename('../../../etc/passwd') // returns 'passwd' + */ +export function sanitizeFilename(fileName: string): string { + // Normalize to forward slashes first to handle Windows paths on Unix + const normalized = fileName.replace(/\\/g, '/'); + + // Extract just the filename, stripping all directory components + let sanitized = path.basename(normalized); + + // Remove null bytes which could be used for null byte injection attacks + sanitized = sanitized.replace(/\0/g, ''); + + // If the result is empty or just dots, use a default name + if (!sanitized || /^\.+$/.test(sanitized)) { + sanitized = 'untitled'; + } + + return sanitized; +}
packages/workflow/test/utils.test.ts+51 −0 modified@@ -15,6 +15,7 @@ import { setSafeObjectProperty, sleepWithAbort, isCommunityPackageName, + sanitizeFilename, } from '../src/utils'; describe('isObjectEmpty', () => { @@ -864,3 +865,53 @@ describe('isCommunityPackageName', () => { expect(isCommunityPackageName('@test-scope/n8n-nodes-test')).toBe(true); }); }); + +describe('sanitizeFilename', () => { + it('should return normal filenames unchanged', () => { + expect(sanitizeFilename('normalfile')).toBe('normalfile'); + expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2'); + expect(sanitizeFilename('test.txt')).toBe('test.txt'); + }); + + it('should handle empty and invalid inputs', () => { + expect(sanitizeFilename('')).toBe('untitled'); + }); + + it('should handle edge cases', () => { + expect(sanitizeFilename('.')).toBe('untitled'); + expect(sanitizeFilename('..')).toBe('untitled'); + }); + + it('should prevent path traversal attacks', () => { + // Basic path traversal attempts - extracts just the filename + expect(sanitizeFilename('../../../etc/passwd')).toBe('passwd'); + expect(sanitizeFilename('..\\..\\..\\windows\\system32')).toBe('system32'); + + // Path traversal with file extension + expect(sanitizeFilename('../file.txt')).toBe('file.txt'); + expect(sanitizeFilename('../../secret.json')).toBe('secret.json'); + + // Nested path separators - extracts just the final component + expect(sanitizeFilename('path/to/file')).toBe('file'); + expect(sanitizeFilename('path\\to\\file')).toBe('file'); + + // Hidden files and nested directories + expect(sanitizeFilename('../../../.ssh/authorized_keys')).toBe('authorized_keys'); + expect(sanitizeFilename('../../../etc/cron.d/backdoor')).toBe('backdoor'); + }); + + it('should extract filename from full file paths', () => { + // Unix paths + expect(sanitizeFilename('/tmp/n8n-upload-xyz/original.pdf')).toBe('original.pdf'); + expect(sanitizeFilename('/home/user/documents/report.docx')).toBe('report.docx'); + + // Windows paths + expect(sanitizeFilename('C:\\Users\\Admin\\file.txt')).toBe('file.txt'); + expect(sanitizeFilename('D:\\temp\\upload\\image.png')).toBe('image.png'); + }); + + it('should remove null bytes', () => { + expect(sanitizeFilename('file\0name.txt')).toBe('filename.txt'); + expect(sanitizeFilename('\0\0\0')).toBe('untitled'); + }); +});
e0baf48c6a54fix(core): Sanitize filenames for file operations (#23988)
6 files changed · +110 −4
packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts+3 −2 modified@@ -17,6 +17,7 @@ import { fileTypeFromMimeType, ApplicationError, UnexpectedError, + sanitizeFilename, } from 'n8n-workflow'; import path from 'path'; import type { Readable } from 'stream'; @@ -211,9 +212,9 @@ export async function copyBinaryFile( }; if (fileName) { - returnData.fileName = fileName; + returnData.fileName = sanitizeFilename(fileName); } else if (filePath) { - returnData.fileName = path.parse(filePath).base; + returnData.fileName = sanitizeFilename(filePath); } return await Container.get(BinaryDataService).copyBinaryFile(
packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts+10 −0 modified@@ -627,6 +627,16 @@ describe('copyBinaryFile', () => { filePath, ); }); + + it('should sanitize filenames', async () => { + await copyBinaryFile(workflowId, executionId, filePath, '../../../etc/passwd'); + + expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( + { type: 'execution', workflowId, executionId }, + expect.objectContaining({ fileName: 'passwd' }), + filePath, + ); + }); }); describe('prepareBinaryData', () => {
packages/nodes-base/nodes/Ssh/Ssh.node.ts+11 −2 modified@@ -9,7 +9,12 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { BINARY_ENCODING, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; +import { + BINARY_ENCODING, + NodeConnectionTypes, + NodeOperationError, + sanitizeFilename, +} from 'n8n-workflow'; import type { Config } from 'node-ssh'; import { NodeSSH } from 'node-ssh'; import type { Readable } from 'stream'; @@ -444,11 +449,15 @@ export class Ssh implements INodeType { try { await writeFile(binaryFile.path, uploadData); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const rawFileName = fileName || binaryData.fileName || ''; + const sanitizedFileName = sanitizeFilename(rawFileName); + await ssh.putFile( binaryFile.path, `${parameterPath}${ parameterPath.charAt(parameterPath.length - 1) === '/' ? '' : '/' - }${fileName || binaryData.fileName}`, + }${sanitizedFileName}`, ); returnItems.push({
packages/workflow/src/index.ts+1 −0 modified@@ -54,6 +54,7 @@ export { isDomainAllowed, isCommunityPackageName, dedupe, + sanitizeFilename, } from './utils'; export { isINodeProperties,
packages/workflow/src/utils.ts+34 −0 modified@@ -4,6 +4,7 @@ import type { Node as SyntaxNode, ExpressionStatement } from 'esprima-next'; import FormData from 'form-data'; import { jsonrepair } from 'jsonrepair'; import merge from 'lodash/merge'; +import path from 'path'; import { ALPHABET } from './constants'; import { ManualExecutionCancelledError } from './errors/execution-cancelled.error'; @@ -465,3 +466,36 @@ export function isCommunityPackageName(packageName: string): boolean { export function dedupe<T>(arr: T[]): T[] { return [...new Set(arr)]; } + +/** + * Extracts a safe filename from a path or filename string. + * + * Handles both Unix and Windows path separators, removing directory + * components and null bytes to return just the filename. + * + * @param fileName - The filename or path to sanitize + * @returns The extracted filename without path components + * + * @example + * sanitizeFilename('path/to/file.txt') // returns 'file.txt' + * sanitizeFilename('/tmp/upload/doc.pdf') // returns 'doc.pdf' + * sanitizeFilename('C:\\Users\\file.txt') // returns 'file.txt' + * sanitizeFilename('../../../etc/passwd') // returns 'passwd' + */ +export function sanitizeFilename(fileName: string): string { + // Normalize to forward slashes first to handle Windows paths on Unix + const normalized = fileName.replace(/\\/g, '/'); + + // Extract just the filename, stripping all directory components + let sanitized = path.basename(normalized); + + // Remove null bytes which could be used for null byte injection attacks + sanitized = sanitized.replace(/\0/g, ''); + + // If the result is empty or just dots, use a default name + if (!sanitized || /^\.+$/.test(sanitized)) { + sanitized = 'untitled'; + } + + return sanitized; +}
packages/workflow/test/utils.test.ts+51 −0 modified@@ -15,6 +15,7 @@ import { setSafeObjectProperty, sleepWithAbort, isCommunityPackageName, + sanitizeFilename, } from '../src/utils'; describe('isObjectEmpty', () => { @@ -912,3 +913,53 @@ describe('isCommunityPackageName', () => { expect(isCommunityPackageName('@test-scope/n8n-nodes-test')).toBe(true); }); }); + +describe('sanitizeFilename', () => { + it('should return normal filenames unchanged', () => { + expect(sanitizeFilename('normalfile')).toBe('normalfile'); + expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2'); + expect(sanitizeFilename('test.txt')).toBe('test.txt'); + }); + + it('should handle empty and invalid inputs', () => { + expect(sanitizeFilename('')).toBe('untitled'); + }); + + it('should handle edge cases', () => { + expect(sanitizeFilename('.')).toBe('untitled'); + expect(sanitizeFilename('..')).toBe('untitled'); + }); + + it('should prevent path traversal attacks', () => { + // Basic path traversal attempts - extracts just the filename + expect(sanitizeFilename('../../../etc/passwd')).toBe('passwd'); + expect(sanitizeFilename('..\\..\\..\\windows\\system32')).toBe('system32'); + + // Path traversal with file extension + expect(sanitizeFilename('../file.txt')).toBe('file.txt'); + expect(sanitizeFilename('../../secret.json')).toBe('secret.json'); + + // Nested path separators - extracts just the final component + expect(sanitizeFilename('path/to/file')).toBe('file'); + expect(sanitizeFilename('path\\to\\file')).toBe('file'); + + // Hidden files and nested directories + expect(sanitizeFilename('../../../.ssh/authorized_keys')).toBe('authorized_keys'); + expect(sanitizeFilename('../../../etc/cron.d/backdoor')).toBe('backdoor'); + }); + + it('should extract filename from full file paths', () => { + // Unix paths + expect(sanitizeFilename('/tmp/n8n-upload-xyz/original.pdf')).toBe('original.pdf'); + expect(sanitizeFilename('/home/user/documents/report.docx')).toBe('report.docx'); + + // Windows paths + expect(sanitizeFilename('C:\\Users\\Admin\\file.txt')).toBe('file.txt'); + expect(sanitizeFilename('D:\\temp\\upload\\image.png')).toBe('image.png'); + }); + + it('should remove null bytes', () => { + expect(sanitizeFilename('file\0name.txt')).toBe('filename.txt'); + expect(sanitizeFilename('\0\0\0')).toBe('untitled'); + }); +});
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
5- github.com/advisories/GHSA-m82q-59gv-mcr9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25055ghsaADVISORY
- github.com/n8n-io/n8n/commit/528ad6b982d0519ec170e172f57b7fdbbe175230ghsaWEB
- github.com/n8n-io/n8n/commit/e0baf48c6a54808f6dbca8cb352bfa306092c223ghsaWEB
- github.com/n8n-io/n8n/security/advisories/GHSA-m82q-59gv-mcr9ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.