This commit is contained in:
Jon 2026-10-16 20:17:57 +02:00 committed by GitHub
commit 05b833b413
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 299 additions and 2 deletions

View File

@ -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',
},
];
}

View File

@ -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<INodeCredentialTestResult> {
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 {

View File

@ -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'],
},
}),
);
});
});