diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 3193f3bd81fbf..48e20db14bcf9 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -497,7 +497,7 @@ export async function parseRequestObject(requestObject: IRequestOptions) { } const host = getHostFromRequestObject(requestObject); - const agentOptions: AgentOptions = {}; + const agentOptions: AgentOptions = { ...requestObject.agentOptions }; if (host) { agentOptions.servername = host; } @@ -505,6 +505,7 @@ export async function parseRequestObject(requestObject: IRequestOptions) { agentOptions.rejectUnauthorized = false; agentOptions.secureOptions = crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT; } + axiosConfig.httpsAgent = new Agent(agentOptions); axiosConfig.beforeRedirect = getBeforeRedirectFn(agentOptions, axiosConfig); diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index c9d4865b8b220..9300a5d9db517 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -1,3 +1,4 @@ +import type { SecureContextOptions } from 'tls'; import { cleanupParameterData, copyInputItems, @@ -387,6 +388,42 @@ describe('NodeExecuteFunctions', () => { expect((axiosOptions.httpsAgent as Agent).options.servername).toEqual('example.de'); }); + describe('should set SSL certificates', () => { + const agentOptions: SecureContextOptions = { + ca: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----', + }; + const requestObject: IRequestOptions = { + method: 'GET', + uri: 'https://example.de', + agentOptions, + }; + + test('on regular requests', async () => { + const axiosOptions = await parseRequestObject(requestObject); + expect((axiosOptions.httpsAgent as Agent).options).toEqual({ + servername: 'example.de', + ...agentOptions, + noDelay: true, + path: null, + }); + }); + + test('on redirected requests', async () => { + const axiosOptions = await parseRequestObject(requestObject); + expect(axiosOptions.beforeRedirect).toBeDefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const redirectOptions: Record = { agents: {}, hostname: 'example.de' }; + axiosOptions.beforeRedirect!(redirectOptions, mock()); + expect(redirectOptions.agent).toEqual(redirectOptions.agents.https); + expect((redirectOptions.agent as Agent).options).toEqual({ + servername: 'example.de', + ...agentOptions, + noDelay: true, + path: null, + }); + }); + }); + describe('when followRedirect is true', () => { test.each(['GET', 'HEAD'] as IHttpRequestMethods[])( 'should set maxRedirects on %s ', diff --git a/packages/nodes-base/credentials/HttpSslAuth.credentials.ts b/packages/nodes-base/credentials/HttpSslAuth.credentials.ts new file mode 100644 index 0000000000000..06be6d4dd74e5 --- /dev/null +++ b/packages/nodes-base/credentials/HttpSslAuth.credentials.ts @@ -0,0 +1,54 @@ +/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed */ +/* eslint-disable n8n-nodes-base/cred-class-field-name-unsuffixed */ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class HttpSslAuth implements ICredentialType { + name = 'httpSslAuth'; + + displayName = 'SSL Certificates'; + + documentationUrl = 'httpRequest'; + + icon = 'node:n8n-nodes-base.httpRequest'; + + properties: INodeProperties[] = [ + { + displayName: 'CA', + name: 'ca', + type: 'string', + description: 'Certificate Authority certificate', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Certificate', + name: 'cert', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Private Key', + name: 'key', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Passphrase', + name: 'passphrase', + type: 'string', + description: 'Optional passphrase for the private key, if the private key is encrypted', + typeOptions: { + password: true, + }, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts index 945277ece5be5..54a37a595330b 100644 --- a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts @@ -1,3 +1,4 @@ +import type { SecureContextOptions } from 'tls'; import type { IDataObject, INodeExecutionData, @@ -8,6 +9,8 @@ import type { import set from 'lodash/set'; import FormData from 'form-data'; +import type { HttpSslAuthCredentials } from './interfaces'; +import { formatPrivateKey } from '../../utils/utilities'; export type BodyParameter = { name: string; @@ -194,3 +197,18 @@ export const prepareRequestBody = async ( return await reduceAsync(parameters, defaultReducer); } }; + +export const setAgentOptions = ( + requestOptions: IRequestOptions, + sslCertificates: HttpSslAuthCredentials | undefined, +) => { + if (sslCertificates) { + const agentOptions: SecureContextOptions = {}; + if (sslCertificates.ca) agentOptions.ca = formatPrivateKey(sslCertificates.ca); + if (sslCertificates.cert) agentOptions.cert = formatPrivateKey(sslCertificates.cert); + if (sslCertificates.key) agentOptions.key = formatPrivateKey(sslCertificates.key); + if (sslCertificates.passphrase) + agentOptions.passphrase = formatPrivateKey(sslCertificates.passphrase); + requestOptions.agentOptions = agentOptions; + } +}; diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 7abbf389723f2..386721c3b1025 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -33,8 +33,10 @@ import { reduceAsync, replaceNullValues, sanitizeUiMessage, + setAgentOptions, } from '../GenericFunctions'; import { keysToLowercase } from '@utils/utilities'; +import type { HttpSslAuthCredentials } from '../interfaces'; function toText(data: T) { if (typeof data === 'object' && data !== null) { @@ -56,7 +58,17 @@ export class HttpRequestV3 implements INodeType { }, inputs: ['main'], outputs: ['main'], - credentials: [], + credentials: [ + { + name: 'httpSslAuth', + required: true, + displayOptions: { + show: { + provideSslCertificates: [true], + }, + }, + }, + ], properties: [ { displayName: '', @@ -173,6 +185,36 @@ export class HttpRequestV3 implements INodeType { }, }, }, + { + displayName: 'SSL Certificates', + name: 'provideSslCertificates', + type: 'boolean', + default: false, + isNodeSetting: true, + }, + { + displayName: "Provide certificates in node's 'Credential for SSL Certificates' parameter", + name: 'provideSslCertificatesNotice', + type: 'notice', + default: '', + isNodeSetting: true, + displayOptions: { + show: { + provideSslCertificates: [true], + }, + }, + }, + { + displayName: 'SSL Certificate', + name: 'sslCertificate', + type: 'credentials', + default: '', + displayOptions: { + show: { + provideSslCertificates: [true], + }, + }, + }, { displayName: 'Send Query Parameters', name: 'sendQuery', @@ -1221,6 +1263,7 @@ export class HttpRequestV3 implements INodeType { let httpCustomAuth; let oAuth1Api; let oAuth2Api; + let sslCertificates; let nodeCredentialType: string | undefined; let genericCredentialType: string | undefined; @@ -1280,6 +1323,19 @@ export class HttpRequestV3 implements INodeType { nodeCredentialType = this.getNodeParameter('nodeCredentialType', itemIndex) as string; } + const provideSslCertificates = this.getNodeParameter( + 'provideSslCertificates', + itemIndex, + false, + ); + + if (provideSslCertificates) { + sslCertificates = (await this.getCredentials( + 'httpSslAuth', + itemIndex, + )) as HttpSslAuthCredentials; + } + const requestMethod = this.getNodeParameter('method', itemIndex) as IHttpRequestMethods; const sendQuery = this.getNodeParameter('sendQuery', itemIndex, false) as boolean; @@ -1575,6 +1631,12 @@ export class HttpRequestV3 implements INodeType { const authDataKeys: IAuthDataSanitizeKeys = {}; + // Add SSL certificates if any are set + setAgentOptions(requestOptions, sslCertificates); + if (requestOptions.agentOptions) { + authDataKeys.agentOptions = Object.keys(requestOptions.agentOptions); + } + // Add credentials if any are set if (httpBasicAuth !== undefined) { requestOptions.auth = { @@ -1594,6 +1656,7 @@ export class HttpRequestV3 implements INodeType { requestOptions.qs[httpQueryAuth.name as string] = httpQueryAuth.value; authDataKeys.qs = [httpQueryAuth.name as string]; } + if (httpDigestAuth !== undefined) { requestOptions.auth = { user: httpDigestAuth.user as string, diff --git a/packages/nodes-base/nodes/HttpRequest/interfaces.ts b/packages/nodes-base/nodes/HttpRequest/interfaces.ts new file mode 100644 index 0000000000000..83ef3532daa2e --- /dev/null +++ b/packages/nodes-base/nodes/HttpRequest/interfaces.ts @@ -0,0 +1,6 @@ +export type HttpSslAuthCredentials = { + ca?: string; + cert?: string; + key?: string; + passphrase?: string; +}; diff --git a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts index 991d0061feaac..716b59e9b800f 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts @@ -1,4 +1,5 @@ -import { prepareRequestBody } from '../../GenericFunctions'; +import type { IRequestOptions } from 'n8n-workflow'; +import { prepareRequestBody, setAgentOptions } from '../../GenericFunctions'; import type { BodyParameter, BodyParametersReducer } from '../../GenericFunctions'; describe('HTTP Node Utils, prepareRequestBody', () => { @@ -33,3 +34,42 @@ describe('HTTP Node Utils, prepareRequestBody', () => { expect(result).toEqual({ foo: { bar: { spam: 'baz' } } }); }); }); + +describe('HTTP Node Utils, setAgentOptions', () => { + it("should not have agentOptions as it's undefined", async () => { + const requestOptions: IRequestOptions = { + method: 'GET', + uri: 'https://example.com', + }; + + const sslCertificates = undefined; + + setAgentOptions(requestOptions, sslCertificates); + + expect(requestOptions).toEqual({ + method: 'GET', + uri: 'https://example.com', + }); + }); + + it('should have agentOptions set', async () => { + const requestOptions: IRequestOptions = { + method: 'GET', + uri: 'https://example.com', + }; + + const sslCertificates = { + ca: 'mock-ca', + }; + + setAgentOptions(requestOptions, sslCertificates); + + expect(requestOptions).toStrictEqual({ + method: 'GET', + uri: 'https://example.com', + agentOptions: { + ca: 'mock-ca', + }, + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 6665da4f5e139..77a204561cf32 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -168,6 +168,7 @@ "dist/credentials/HttpHeaderAuth.credentials.js", "dist/credentials/HttpCustomAuth.credentials.js", "dist/credentials/HttpQueryAuth.credentials.js", + "dist/credentials/HttpSslAuth.credentials.js", "dist/credentials/HubspotApi.credentials.js", "dist/credentials/HubspotAppToken.credentials.js", "dist/credentials/HubspotDeveloperApi.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 84ccddbd12831..4b86e80987306 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -4,6 +4,7 @@ import type * as express from 'express'; import type FormData from 'form-data'; import type { PathLike } from 'fs'; import type { IncomingHttpHeaders } from 'http'; +import type { SecureContextOptions } from 'tls'; import type { Readable } from 'stream'; import type { URLSearchParams } from 'url'; @@ -547,6 +548,8 @@ export interface IRequestOptions { /** Max number of redirects to follow @default 21 */ maxRedirects?: number; + + agentOptions?: SecureContextOptions; } export interface PaginationOptions {