diff --git a/packages/nodes-base/credentials/Sftp.credentials.ts b/packages/nodes-base/credentials/Sftp.credentials.ts index 3238097d6d7..ec48217f72a 100644 --- a/packages/nodes-base/credentials/Sftp.credentials.ts +++ b/packages/nodes-base/credentials/Sftp.credentials.ts @@ -57,5 +57,88 @@ export class Sftp implements ICredentialType { default: '', description: 'For an encrypted private key, this is the passphrase used to decrypt it', }, + { + displayName: 'Advanced Configuration', + name: 'advancedConfiguration', + type: 'boolean', + default: false, + description: 'Whether to configure custom SSH2 algorithms', + }, + { + displayName: 'Encryption Ciphers', + name: 'algorithmsCiphers', + type: 'string', + default: '', + displayOptions: { + show: { + advancedConfiguration: [true], + }, + }, + placeholder: 'e.g. aes128-ctr,aes192-ctr,aes256-ctr', + hint: 'Optional comma-separated list of cipher algorithms. Leave empty to use defaults.', + description: + 'Symmetric cipher algorithms for payload encryption. Common values: aes128-ctr, aes192-ctr, aes256-ctr, aes256-gcm@openssh.com', + }, + { + displayName: 'Compression Algorithms', + name: 'algorithmsCompression', + type: 'string', + default: '', + displayOptions: { + show: { + advancedConfiguration: [true], + }, + }, + placeholder: 'e.g. zlib@openssh.com,zlib,none', + hint: 'Optional comma-separated list of compression algorithms. Leave empty to use defaults.', + description: + 'Compression algorithms for the connection. Common values: zlib@openssh.com, zlib, none', + }, + { + displayName: 'HMAC Algorithms', + name: 'algorithmsHmac', + type: 'string', + default: '', + displayOptions: { + show: { + advancedConfiguration: [true], + }, + }, + placeholder: 'e.g. hmac-sha2-256,hmac-sha2-512', + hint: 'Optional comma-separated list of HMAC algorithms. Leave empty to use defaults.', + description: + 'Message authentication code algorithms. Common values: hmac-sha2-256, hmac-sha2-512, hmac-sha1', + }, + { + displayName: 'Key Exchange Algorithms', + name: 'algorithmsKex', + type: 'string', + default: '', + displayOptions: { + show: { + advancedConfiguration: [true], + }, + }, + placeholder: 'e.g. ecdh-sha2-nistp256,diffie-hellman-group14-sha256', + hint: 'Optional comma-separated list of key exchange algorithms. Leave empty to use defaults.', + description: + 'Key exchange algorithms for establishing the connection. Common values: ecdh-sha2-nistp256, diffie-hellman-group14-sha256', + }, + { + displayName: 'Server Host Key Formats', + name: 'algorithmsServerHostKey', + // eslint-disable-next-line n8n-nodes-base/cred-class-field-type-options-password-missing + type: 'string', + default: '', + displayOptions: { + show: { + advancedConfiguration: [true], + }, + }, + placeholder: 'e.g. ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-256', + hint: 'Optional comma-separated list of server host key formats. Leave empty to use defaults.', + description: + 'Server host key signature formats. Common values: ssh-ed25519, ecdsa-sha2-nistp256, rsa-sha2-256, ssh-rsa', + }, ]; } diff --git a/packages/nodes-base/nodes/Ftp/Ftp.node.ts b/packages/nodes-base/nodes/Ftp/Ftp.node.ts index b27167b542c..5f613b8feef 100644 --- a/packages/nodes-base/nodes/Ftp/Ftp.node.ts +++ b/packages/nodes-base/nodes/Ftp/Ftp.node.ts @@ -14,6 +14,7 @@ import type { INodeType, INodeTypeDescription, JsonObject, + Logger, } from 'n8n-workflow'; import { basename, dirname } from 'path'; import ftpClient from 'promise-ftp'; @@ -121,6 +122,63 @@ const timeoutOption: INodeProperties = { default: 10000, }; +/** + * Parse comma-separated algorithm string into array + */ +function parseAlgorithmList(value: string | undefined): string[] | undefined { + if (!value || value.trim() === '') { + return undefined; + } + return value + .split(',') + .map((algo) => algo.trim()) + .filter((algo) => algo !== ''); +} + +/** + * Build algorithms object from SFTP credentials + */ +function buildAlgorithmsConfig(credentials: ICredentialDataDecryptedObject): any { + const algorithms: { + compress?: string[]; + cipher?: string[]; + hmac?: string[]; + kex?: string[]; + serverHostKey?: string[]; + } = {}; + + const compression = parseAlgorithmList(credentials.algorithmsCompression as string); + if (compression) algorithms.compress = compression; + + const ciphers = parseAlgorithmList(credentials.algorithmsCiphers as string); + if (ciphers) algorithms.cipher = ciphers; + + const hmac = parseAlgorithmList(credentials.algorithmsHmac as string); + if (hmac) algorithms.hmac = hmac; + + const kex = parseAlgorithmList(credentials.algorithmsKex as string); + if (kex) algorithms.kex = kex; + + const serverHostKey = parseAlgorithmList(credentials.algorithmsServerHostKey as string); + if (serverHostKey) algorithms.serverHostKey = serverHostKey; + + // Only return if at least one algorithm category is configured + return Object.keys(algorithms).length > 0 ? algorithms : undefined; +} + +/** + * Create SSH2 debug function that logs to n8n logger + */ +function createSsh2DebugFunction( + logger: Logger, + nodeType: string, + nodeName: string, +): (message: string) => void { + return (message: string) => { + logger.debug(`[${nodeType}] [${nodeName}] SSH2 Protocol`, { message }); + }; +} + export class Ftp implements INodeType { description: INodeTypeDescription = { displayName: 'FTP', @@ -551,6 +609,7 @@ export class Ftp implements INodeType { ): Promise { const credentials = credential.data as ICredentialDataDecryptedObject; const sftp = new sftpClient(); + const algorithmsConfig = buildAlgorithmsConfig(credentials); try { if (credentials.privateKey) { await sftp.connect({ @@ -560,6 +619,8 @@ export class Ftp implements INodeType { password: (credentials.password as string) || undefined, privateKey: formatPemBlock(credentials.privateKey as string), passphrase: credentials.passphrase as string | undefined, + algorithms: algorithmsConfig, + debug: createSsh2DebugFunction(this.logger, 'SFTP', 'Credential Test'), }); } else { await sftp.connect({ @@ -567,6 +628,8 @@ export class Ftp implements INodeType { port: credentials.port as number, username: credentials.username as string, password: credentials.password as string, + algorithms: algorithmsConfig, + debug: createSsh2DebugFunction(this.logger, 'SFTP', 'Credential Test'), }); } } catch (error) { @@ -608,6 +671,9 @@ export class Ftp implements INodeType { try { if (protocol === 'sftp') { sftp = new sftpClient(); + const algorithmsConfig = buildAlgorithmsConfig(credentials); + const nodeType = this.getNode().type; + const nodeName = this.getNode().name; if (credentials.privateKey) { await sftp.connect({ host: credentials.host as string, @@ -617,9 +683,10 @@ export class Ftp implements INodeType { privateKey: formatPemBlock(credentials.privateKey as string), passphrase: credentials.passphrase as string | undefined, readyTimeout: connectionTimeout, - algorithms: { + algorithms: algorithmsConfig || { compress: ['zlib@openssh.com', 'zlib', 'none'], }, + debug: createSsh2DebugFunction(this.logger, nodeType, nodeName), }); } else { await sftp.connect({ @@ -628,9 +695,10 @@ export class Ftp implements INodeType { username: credentials.username as string, password: credentials.password as string, readyTimeout: connectionTimeout, - algorithms: { + algorithms: algorithmsConfig || { compress: ['zlib@openssh.com', 'zlib', 'none'], }, + debug: createSsh2DebugFunction(this.logger, nodeType, nodeName), }); } } else { diff --git a/packages/nodes-base/nodes/Ftp/__test__/Ftp.node.test.ts b/packages/nodes-base/nodes/Ftp/__test__/Ftp.node.test.ts index 950ea5cf2f9..cd9b6d31dc5 100644 --- a/packages/nodes-base/nodes/Ftp/__test__/Ftp.node.test.ts +++ b/packages/nodes-base/nodes/Ftp/__test__/Ftp.node.test.ts @@ -21,6 +21,10 @@ describe('Ftp', () => { executeFunctions.helpers.constructExecutionMetaData.mockImplementation( (data) => data as NodeExecutionWithMetadata[], ); + executeFunctions.getNode.mockReturnValue({ + type: 'n8n-nodes-base.ftp', + name: 'FTP', + } as any); }); it('should add timeout option with ftp', async () => { @@ -208,4 +212,146 @@ describe('Ftp', () => { }), ); }); + + it('should parse and apply custom algorithms with sftp', async () => { + const connect = jest.fn(); + jest.spyOn(sftpModule, 'default').mockImplementation( + () => + ({ + connect, + delete: jest.fn(), + end: jest.fn(), + }) as unknown as sftp, + ); + executeFunctions.getCredentials.mockResolvedValue({ + host: 'test.com', + port: 22, + username: 'test', + password: 'test', + algorithmsCiphers: 'aes128-ctr,aes256-ctr', + algorithmsCompression: 'none', + algorithmsHmac: 'hmac-sha2-256', + algorithmsKex: 'ecdh-sha2-nistp256', + algorithmsServerHostKey: 'ssh-ed25519', + }); + executeFunctions.getNodeParameter.mockImplementation((parameterName, _idx, defaultValue) => { + switch (parameterName) { + case 'operation': + return 'delete'; + case 'protocol': + return 'sftp'; + case 'options.timeout': + return 10000; + case 'path': + return '/test/path'; + case 'options': + return {}; + default: + return defaultValue; + } + }); + + await new Ftp().execute.call(executeFunctions); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + algorithms: { + cipher: ['aes128-ctr', 'aes256-ctr'], + compress: ['none'], + hmac: ['hmac-sha2-256'], + kex: ['ecdh-sha2-nistp256'], + serverHostKey: ['ssh-ed25519'], + }, + }), + ); + }); + + it('should handle whitespace in algorithm lists', async () => { + const connect = jest.fn(); + jest.spyOn(sftpModule, 'default').mockImplementation( + () => + ({ + connect, + delete: jest.fn(), + end: jest.fn(), + }) as unknown as sftp, + ); + executeFunctions.getCredentials.mockResolvedValue({ + host: 'test.com', + port: 22, + username: 'test', + password: 'test', + algorithmsCiphers: ' aes128-ctr , aes256-ctr ', + }); + executeFunctions.getNodeParameter.mockImplementation((parameterName, _idx, defaultValue) => { + switch (parameterName) { + case 'operation': + return 'delete'; + case 'protocol': + return 'sftp'; + case 'options.timeout': + return 10000; + case 'path': + return '/test/path'; + case 'options': + return {}; + default: + return defaultValue; + } + }); + + await new Ftp().execute.call(executeFunctions); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + algorithms: { + cipher: ['aes128-ctr', 'aes256-ctr'], + }, + }), + ); + }); + + it('should use default compression when no algorithms configured', async () => { + const connect = jest.fn(); + jest.spyOn(sftpModule, 'default').mockImplementation( + () => + ({ + connect, + delete: jest.fn(), + end: jest.fn(), + }) as unknown as sftp, + ); + executeFunctions.getCredentials.mockResolvedValue({ + host: 'test.com', + port: 22, + username: 'test', + password: 'test', + }); + executeFunctions.getNodeParameter.mockImplementation((parameterName, _idx, defaultValue) => { + switch (parameterName) { + case 'operation': + return 'delete'; + case 'protocol': + return 'sftp'; + case 'options.timeout': + return 10000; + case 'path': + return '/test/path'; + case 'options': + return {}; + default: + return defaultValue; + } + }); + + await new Ftp().execute.call(executeFunctions); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + algorithms: { + compress: ['zlib@openssh.com', 'zlib', 'none'], + }, + }), + ); + }); });