VYPR
High severityNVD Advisory· Published Feb 4, 2026· Updated Feb 5, 2026

n8n Arbitrary File Write on Remote Systems via SSH Node

CVE-2026-25055

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.

PackageAffected versionsPatched versions
n8nnpm
>= 2.0.0, < 2.4.02.4.0
n8nnpm
< 1.123.121.123.12

Affected products

1

Patches

2
528ad6b982d0

fix(core): Sanitize filenames for file operations (#24221)

https://github.com/n8n-io/n8nDawid MyslakJan 13, 2026via ghsa
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');
    +	});
    +});
    
e0baf48c6a54

fix(core): Sanitize filenames for file operations (#23988)

https://github.com/n8n-io/n8nDawid MyslakJan 9, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.