From e84c27c0cebd6fba135298ea18844045dcf55b4c Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:46:39 +0200 Subject: [PATCH] feat(Webhook Node): Overhaul (#8889) Co-authored-by: Giulio Andreini --- cypress/e2e/16-webhook-node.cy.ts | 9 +- packages/cli/src/AbstractServer.ts | 4 +- packages/cli/src/WebhookHelpers.ts | 32 +++- .../webhook-not-found.error.ts | 2 +- .../test-webhook-registrations.service.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 28 ++++ packages/nodes-base/credentials/icons/jwt.svg | 112 ++++++++++++++ .../credentials/jwtAuth.credentials.ts | 138 +++++++++++++++++ .../RespondToWebhook/RespondToWebhook.node.ts | 91 ++++++++++- .../nodes-base/nodes/Webhook/Webhook.node.ts | 129 +++++++++++++--- .../nodes-base/nodes/Webhook/description.ts | 104 ++++++++++++- .../nodes/Webhook/test/Webhook.test.ts | 4 + packages/nodes-base/nodes/Webhook/utils.ts | 141 ++++++++++++++++++ packages/nodes-base/package.json | 1 + packages/workflow/src/Interfaces.ts | 9 +- packages/workflow/src/NodeHelpers.ts | 11 +- packages/workflow/src/Workflow.ts | 6 + 17 files changed, 780 insertions(+), 43 deletions(-) create mode 100644 packages/nodes-base/credentials/icons/jwt.svg create mode 100644 packages/nodes-base/credentials/jwtAuth.credentials.ts create mode 100644 packages/nodes-base/nodes/Webhook/utils.ts diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 8abb17284d..560fc41056 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -48,11 +48,10 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { } if (responseCode) { - cy.getByTestId('parameter-input-responseCode') - .find('.parameter-input') - .find('input') - .clear() - .type(responseCode.toString()); + cy.get('.param-options').click(); + getVisibleSelect().contains('Response Code').click(); + cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click(); + getVisibleSelect().contains('201').click(); } if (respondWith) { diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index b2773c45ea..89f521a4f1 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -115,12 +115,12 @@ export abstract class AbstractServer { private async setupHealthCheck() { // health check should not care about DB connections - this.app.get('/healthz', async (req, res) => { + this.app.get('/healthz', async (_req, res) => { res.send({ status: 'ok' }); }); const { connectionState } = Db; - this.app.use((req, res, next) => { + this.app.use((_req, res, next) => { if (connectionState.connected) { if (connectionState.migrated) next(); else res.send('n8n is starting up. Please wait'); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index e840d3f3d4..380bf2895f 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -106,10 +106,22 @@ export const webhookRequestHandler = const options = await webhookManager.findAccessControlOptions(path, requestedMethod); const { allowedOrigins } = options ?? {}; - res.header( - 'Access-Control-Allow-Origin', - !allowedOrigins || allowedOrigins === '*' ? req.headers.origin : allowedOrigins, - ); + if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) { + const originsList = allowedOrigins.split(','); + const defaultOrigin = originsList[0]; + + if (originsList.length === 1) { + res.header('Access-Control-Allow-Origin', defaultOrigin); + } + + if (originsList.includes(req.headers.origin as string)) { + res.header('Access-Control-Allow-Origin', req.headers.origin); + } else { + res.header('Access-Control-Allow-Origin', defaultOrigin); + } + } else { + res.header('Access-Control-Allow-Origin', req.headers.origin); + } if (method === 'OPTIONS') { res.header('Access-Control-Max-Age', '300'); @@ -262,14 +274,14 @@ export async function executeWebhook( ); const responseCode = workflow.expression.getSimpleParameterValue( workflowStartNode, - webhookData.webhookDescription.responseCode, + webhookData.webhookDescription.responseCode as string, executionMode, additionalKeys, undefined, 200, ) as number; - const responseData = workflow.expression.getSimpleParameterValue( + const responseData = workflow.expression.getComplexParameterValue( workflowStartNode, webhookData.webhookDescription.responseData, executionMode, @@ -324,7 +336,7 @@ export async function executeWebhook( // TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly }); req.body = await new Promise((resolve) => { - form.parse(req, async (err, data, files) => { + form.parse(req, async (_err, data, files) => { normalizeFormData(data); normalizeFormData(files); resolve({ data, files }); @@ -455,6 +467,12 @@ export async function executeWebhook( responseCallback(null, { responseCode, }); + } else if (responseData) { + // Return the data specified in the response data option + responseCallback(null, { + data: responseData as IDataObject, + responseCode, + }); } else if (webhookResultData.webhookResponse !== undefined) { // Data to respond with is given responseCallback(null, { diff --git a/packages/cli/src/errors/response-errors/webhook-not-found.error.ts b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts index 617c119a1e..648a7a0106 100644 --- a/packages/cli/src/errors/response-errors/webhook-not-found.error.ts +++ b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts @@ -49,7 +49,7 @@ export class WebhookNotFoundError extends NotFoundError { const hintMsg = hint === 'default' - ? "Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)" + ? "Click the 'Test workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)" : "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"; super(errorMsg, hintMsg); diff --git a/packages/cli/src/services/test-webhook-registrations.service.ts b/packages/cli/src/services/test-webhook-registrations.service.ts index 098562e54a..3c04770084 100644 --- a/packages/cli/src/services/test-webhook-registrations.service.ts +++ b/packages/cli/src/services/test-webhook-registrations.service.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import { CacheService } from '@/services/cache/cache.service'; -import { type IWebhookData } from 'n8n-workflow'; +import type { IWebhookData } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index cbbbc3e369..51f66c1574 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -92,6 +92,7 @@ import type { NodeExecutionWithMetadata, NodeHelperFunctions, NodeParameterValueType, + NodeTypeAndVersion, PaginationOptions, RequestHelperFunctions, Workflow, @@ -2798,7 +2799,34 @@ const getCommonWorkflowFunctions = ( active: workflow.active, }), getWorkflowStaticData: (type) => workflow.getStaticData(type, node), + getChildNodes: (nodeName: string) => { + const output: NodeTypeAndVersion[] = []; + const nodes = workflow.getChildNodes(nodeName); + for (const nodeName of nodes) { + const node = workflow.nodes[nodeName]; + output.push({ + name: node.name, + type: node.type, + typeVersion: node.typeVersion, + }); + } + return output; + }, + getParentNodes: (nodeName: string) => { + const output: NodeTypeAndVersion[] = []; + const nodes = workflow.getParentNodes(nodeName); + + for (const nodeName of nodes) { + const node = workflow.nodes[nodeName]; + output.push({ + name: node.name, + type: node.type, + typeVersion: node.typeVersion, + }); + } + return output; + }, getRestApiUrl: () => additionalData.restApiUrl, getInstanceBaseUrl: () => additionalData.instanceBaseUrl, getInstanceId: () => Container.get(InstanceSettings).instanceId, diff --git a/packages/nodes-base/credentials/icons/jwt.svg b/packages/nodes-base/credentials/icons/jwt.svg new file mode 100644 index 0000000000..f29c89d2bd --- /dev/null +++ b/packages/nodes-base/credentials/icons/jwt.svg @@ -0,0 +1,112 @@ + + + + Group + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Group + + + + diff --git a/packages/nodes-base/credentials/jwtAuth.credentials.ts b/packages/nodes-base/credentials/jwtAuth.credentials.ts new file mode 100644 index 0000000000..d5c772fe5f --- /dev/null +++ b/packages/nodes-base/credentials/jwtAuth.credentials.ts @@ -0,0 +1,138 @@ +import type { ICredentialType, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; + +const algorithms: INodePropertyOptions[] = [ + { + name: 'HS256', + value: 'HS256', + }, + { + name: 'HS384', + value: 'HS384', + }, + { + name: 'HS512', + value: 'HS512', + }, + { + name: 'RS256', + value: 'RS256', + }, + { + name: 'RS384', + value: 'RS384', + }, + { + name: 'RS512', + value: 'RS512', + }, + { + name: 'ES256', + value: 'ES256', + }, + { + name: 'ES384', + value: 'ES384', + }, + { + name: 'ES512', + value: 'ES512', + }, + { + name: 'PS256', + value: 'PS256', + }, + { + name: 'PS384', + value: 'PS384', + }, + { + name: 'PS512', + value: 'PS512', + }, + { + name: 'none', + value: 'none', + }, +]; + +// eslint-disable-next-line n8n-nodes-base/cred-class-name-unsuffixed +export class jwtAuth implements ICredentialType { + // eslint-disable-next-line n8n-nodes-base/cred-class-field-name-unsuffixed + name = 'jwtAuth'; + + displayName = 'JWT Auth'; + + documentationUrl = 'jwtAuth'; + + icon = 'file:icons/jwt.svg'; + + properties: INodeProperties[] = [ + { + displayName: 'Key Type', + name: 'keyType', + type: 'options', + description: + 'Choose either the secret passphrase for PEM encoded public keys for RSA and ECDSA', + options: [ + { + name: 'Passphrase', + value: 'passphrase', + }, + { + name: 'PEM Key', + value: 'pemKey', + }, + ], + default: 'passphrase', + }, + { + displayName: 'Secret', + name: 'secret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + displayOptions: { + show: { + keyType: ['passphrase'], + }, + }, + }, + { + displayName: 'Private Key', + name: 'privateKey', + type: 'string', + typeOptions: { + password: true, + }, + displayOptions: { + show: { + keyType: ['pemKey'], + }, + }, + default: '', + }, + { + displayName: 'Public Key', + name: 'publicKey', + type: 'string', + typeOptions: { + password: true, + }, + displayOptions: { + show: { + keyType: ['pemKey'], + }, + }, + default: '', + }, + { + displayName: 'Algorithm', + name: 'algorithm', + type: 'options', + default: 'HS256', + options: algorithms, + }, + ]; +} diff --git a/packages/nodes-base/nodes/RespondToWebhook/RespondToWebhook.node.ts b/packages/nodes-base/nodes/RespondToWebhook/RespondToWebhook.node.ts index ca16a82b04..fb1c2db13f 100644 --- a/packages/nodes-base/nodes/RespondToWebhook/RespondToWebhook.node.ts +++ b/packages/nodes-base/nodes/RespondToWebhook/RespondToWebhook.node.ts @@ -10,6 +10,8 @@ import type { } from 'n8n-workflow'; import { jsonParse, BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; import set from 'lodash/set'; +import jwt from 'jsonwebtoken'; +import { formatPrivateKey } from '../../utils/utilities'; export class RespondToWebhook implements INodeType { description: INodeTypeDescription = { @@ -17,14 +19,24 @@ export class RespondToWebhook implements INodeType { icon: 'file:webhook.svg', name: 'respondToWebhook', group: ['transform'], - version: 1, + version: [1, 1.1], description: 'Returns data for Webhook', defaults: { name: 'Respond to Webhook', }, inputs: ['main'], outputs: ['main'], - credentials: [], + credentials: [ + { + name: 'jwtAuth', + required: true, + displayOptions: { + show: { + respondWith: ['jwt'], + }, + }, + }, + ], properties: [ { displayName: @@ -58,6 +70,11 @@ export class RespondToWebhook implements INodeType { value: 'json', description: 'Respond with a custom JSON body', }, + { + name: 'JWT Token', + value: 'jwt', + description: 'Respond with a JWT token', + }, { name: 'No Data', value: 'noData', @@ -77,6 +94,17 @@ export class RespondToWebhook implements INodeType { default: 'firstIncomingItem', description: 'The data that should be returned', }, + { + displayName: 'Credentials', + name: 'credentials', + type: 'credentials', + default: '', + displayOptions: { + show: { + respondWith: ['jwt'], + }, + }, + }, { displayName: 'When using expressions, note that this node will only run for the first item in the input data', @@ -84,7 +112,7 @@ export class RespondToWebhook implements INodeType { type: 'notice', displayOptions: { show: { - respondWith: ['json', 'text'], + respondWith: ['json', 'text', 'jwt'], }, }, default: '', @@ -119,6 +147,22 @@ export class RespondToWebhook implements INodeType { }, description: 'The HTTP response JSON data', }, + { + displayName: 'Payload', + name: 'payload', + type: 'json', + displayOptions: { + show: { + respondWith: ['jwt'], + }, + }, + default: '{\n "myField": "value"\n}', + typeOptions: { + rows: 4, + }, + validateType: 'object', + description: 'The payload to include in the JWT token', + }, { displayName: 'Response Body', name: 'responseBody', @@ -243,6 +287,21 @@ export class RespondToWebhook implements INodeType { }; async execute(this: IExecuteFunctions): Promise { + const nodeVersion = this.getNode().typeVersion; + + if (nodeVersion >= 1.1) { + const connectedNodes = this.getParentNodes(this.getNode().name); + if (!connectedNodes.some((node) => node.type === 'n8n-nodes-base.webhook')) { + throw new NodeOperationError( + this.getNode(), + new Error('No Webhook node found in the workflow'), + { + description: + 'Insert a Webhook node to your workflow and set the “Respond” parameter to “Using Respond to Webhook Node” ', + }, + ); + } + } const items = this.getInputData(); const respondWith = this.getNodeParameter('respondWith', 0) as string; @@ -277,6 +336,32 @@ export class RespondToWebhook implements INodeType { } } } + } else if (respondWith === 'jwt') { + try { + const { keyType, secret, algorithm, privateKey } = (await this.getCredentials( + 'jwtAuth', + )) as { + keyType: 'passphrase' | 'pemKey'; + privateKey: string; + secret: string; + algorithm: jwt.Algorithm; + }; + + let secretOrPrivateKey; + + if (keyType === 'passphrase') { + secretOrPrivateKey = secret; + } else { + secretOrPrivateKey = formatPrivateKey(privateKey); + } + const payload = this.getNodeParameter('payload', 0, {}) as IDataObject; + const token = jwt.sign(payload, secretOrPrivateKey, { algorithm }); + responseBody = { token }; + } catch (error) { + throw new NodeOperationError(this.getNode(), error as Error, { + message: 'Error signing JWT token', + }); + } } else if (respondWith === 'allIncomingItems') { const respondItems = items.map((item) => item.json); responseBody = options.responseKey diff --git a/packages/nodes-base/nodes/Webhook/Webhook.node.ts b/packages/nodes-base/nodes/Webhook/Webhook.node.ts index 03fff2f5df..90d832ec5b 100644 --- a/packages/nodes-base/nodes/Webhook/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook/Webhook.node.ts @@ -10,6 +10,7 @@ import type { INodeTypeDescription, IWebhookResponseData, MultiPartFormData, + INodeProperties, } from 'n8n-workflow'; import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow'; @@ -17,6 +18,7 @@ import { v4 as uuid } from 'uuid'; import basicAuth from 'basic-auth'; import isbot from 'isbot'; import { file as tmpFile } from 'tmp-promise'; +import jwt from 'jsonwebtoken'; import { authenticationProperty, @@ -25,11 +27,19 @@ import { httpMethodsProperty, optionsProperty, responseBinaryPropertyNameProperty, + responseCodeOption, responseCodeProperty, responseDataProperty, responseModeProperty, } from './description'; import { WebhookAuthorizationError } from './error'; +import { + checkResponseModeConfiguration, + configuredOutputs, + isIpWhitelisted, + setupOutputConnection, +} from './utils'; +import { formatPrivateKey } from '../../utils/utilities'; export class Webhook extends Node { authPropertyName = 'authentication'; @@ -39,7 +49,7 @@ export class Webhook extends Node { icon: 'file:webhook.svg', name: 'webhook', group: ['trigger'], - version: [1, 1.1], + version: [1, 1.1, 2], description: 'Starts the workflow when a webhook is called', eventTriggerDescription: 'Waiting for you to call the Test URL', activationMessage: 'You can now make calls to your production webhook URL.', @@ -56,15 +66,14 @@ export class Webhook extends Node { 'Webhooks have two modes: test and production.

Use test mode while you build your workflow. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.

Use production mode to run your workflow automatically. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the executions list, but not in the editor.', }, activationHint: - 'Once you’ve finished building your workflow, run it without having to click this button by using the production webhook URL.', + "Once you've finished building your workflow, run it without having to click this button by using the production webhook URL.", }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], - outputs: ['main'], + outputs: `={{(${configuredOutputs})($parameter)}}`, credentials: credentialsProperty(this.authPropertyName), webhooks: [defaultWebhookDescription], properties: [ - authenticationProperty(this.authPropertyName), httpMethodsProperty, { displayName: 'Path', @@ -73,8 +82,10 @@ export class Webhook extends Node { default: '', placeholder: 'webhook', required: true, - description: 'The path to listen to', + description: + "The path to listen to, dynamic values could be specified by using ':', e.g. 'your-path/:dynamic-value'. If dynamic values are set 'webhookId' would be prepended to path.", }, + authenticationProperty(this.authPropertyName), responseModeProperty, { displayName: @@ -88,27 +99,63 @@ export class Webhook extends Node { }, default: '', }, - responseCodeProperty, + { + ...responseCodeProperty, + displayOptions: { + show: { + '@version': [1, 1.1], + }, + hide: { + responseMode: ['responseNode'], + }, + }, + }, responseDataProperty, responseBinaryPropertyNameProperty, - optionsProperty, + + { + ...optionsProperty, + options: [...(optionsProperty.options as INodeProperties[]), responseCodeOption].sort( + (a, b) => { + const nameA = a.displayName.toUpperCase(); + const nameB = b.displayName.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }, + ), + }, ], }; async webhook(context: IWebhookFunctions): Promise { + const { typeVersion: nodeVersion, type: nodeType } = context.getNode(); + + if (nodeVersion >= 2 && nodeType === 'n8n-nodes-base.webhook') { + checkResponseModeConfiguration(context); + } + const options = context.getNodeParameter('options', {}) as { binaryData: boolean; ignoreBots: boolean; rawBody: boolean; responseData?: string; + ipWhitelist?: string; }; const req = context.getRequestObject(); const resp = context.getResponseObject(); + if (!isIpWhitelisted(options.ipWhitelist, req.ips, req.ip)) { + resp.writeHead(403); + resp.end('IP is not whitelisted to access the webhook!'); + return { noWebhookResponse: true }; + } + + let validationData: IDataObject | undefined; try { if (options.ignoreBots && isbot(req.headers['user-agent'])) throw new WebhookAuthorizationError(403); - await this.validateAuth(context); + validationData = await this.validateAuth(context); } catch (error) { if (error instanceof WebhookAuthorizationError) { resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' }); @@ -118,18 +165,21 @@ export class Webhook extends Node { throw error; } + const prepareOutput = setupOutputConnection(context, { + jwtPayload: validationData, + }); + if (options.binaryData) { - return await this.handleBinaryData(context); + return await this.handleBinaryData(context, prepareOutput); } if (req.contentType === 'multipart/form-data') { - return await this.handleFormData(context); + return await this.handleFormData(context, prepareOutput); } - const nodeVersion = context.getNode().typeVersion; if (nodeVersion > 1 && !req.body && !options.rawBody) { try { - return await this.handleBinaryData(context); + return await this.handleBinaryData(context, prepareOutput); } catch (error) {} } @@ -156,7 +206,7 @@ export class Webhook extends Node { return { webhookResponse: options.responseData, - workflowData: [[response]], + workflowData: prepareOutput(response), }; } @@ -208,10 +258,52 @@ export class Webhook extends Node { // Provided authentication data is wrong throw new WebhookAuthorizationError(403); } + } else if (authentication === 'jwtAuth') { + let expectedAuth; + + try { + expectedAuth = (await context.getCredentials('jwtAuth')) as { + keyType: 'passphrase' | 'pemKey'; + publicKey: string; + secret: string; + algorithm: jwt.Algorithm; + }; + } catch {} + + if (expectedAuth === undefined) { + // Data is not defined on node so can not authenticate + throw new WebhookAuthorizationError(500, 'No authentication data defined on node!'); + } + + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + throw new WebhookAuthorizationError(401, 'No token provided'); + } + + let secretOrPublicKey; + + if (expectedAuth.keyType === 'passphrase') { + secretOrPublicKey = expectedAuth.secret; + } else { + secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey); + } + + try { + return jwt.verify(token, secretOrPublicKey, { + algorithms: [expectedAuth.algorithm], + }) as IDataObject; + } catch (error) { + throw new WebhookAuthorizationError(403, error.message); + } } } - private async handleFormData(context: IWebhookFunctions) { + private async handleFormData( + context: IWebhookFunctions, + prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][], + ) { const req = context.getRequestObject() as MultiPartFormData.Request; const options = context.getNodeParameter('options', {}) as IDataObject; const { data, files } = req.body; @@ -264,10 +356,13 @@ export class Webhook extends Node { } } - return { workflowData: [[returnItem]] }; + return { workflowData: prepareOutput(returnItem) }; } - private async handleBinaryData(context: IWebhookFunctions): Promise { + private async handleBinaryData( + context: IWebhookFunctions, + prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][], + ): Promise { const req = context.getRequestObject(); const options = context.getNodeParameter('options', {}) as IDataObject; @@ -298,7 +393,7 @@ export class Webhook extends Node { returnItem.binary = { [binaryPropertyName]: binaryData }; } - return { workflowData: [[returnItem]] }; + return { workflowData: prepareOutput(returnItem) }; } catch (error) { throw new NodeOperationError(context.getNode(), error as Error); } finally { diff --git a/packages/nodes-base/nodes/Webhook/description.ts b/packages/nodes-base/nodes/Webhook/description.ts index 2f4cc3fdeb..7039ae8490 100644 --- a/packages/nodes-base/nodes/Webhook/description.ts +++ b/packages/nodes-base/nodes/Webhook/description.ts @@ -1,13 +1,13 @@ import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow'; +import { getResponseCode, getResponseData } from './utils'; export const defaultWebhookDescription: IWebhookDescription = { name: 'default', httpMethod: '={{$parameter["httpMethod"] || "GET"}}', isFullPath: true, - responseCode: '={{$parameter["responseCode"]}}', + responseCode: `={{(${getResponseCode})($parameter)}}`, responseMode: '={{$parameter["responseMode"]}}', - responseData: - '={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}', + responseData: `={{(${getResponseData})($parameter)}}`, responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', responseContentType: '={{$parameter["options"]["responseContentType"]}}', responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', @@ -36,6 +36,15 @@ export const credentialsProperty = ( }, }, }, + { + name: 'jwtAuth', + required: true, + displayOptions: { + show: { + [propertyName]: ['jwtAuth'], + }, + }, + }, ]; export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({ @@ -51,6 +60,10 @@ export const authenticationProperty = (propertyName = 'authentication'): INodePr name: 'Header Auth', value: 'headerAuth', }, + { + name: 'JWT Auth', + value: 'jwtAuth', + }, { name: 'None', value: 'none', @@ -243,6 +256,14 @@ export const optionsProperty: INodeProperties = { default: false, description: 'Whether to ignore requests from bots like link previewers and web crawlers', }, + { + displayName: 'IP(s) Whitelist', + name: 'ipWhitelist', + type: 'string', + placeholder: 'e.g. 127.0.0.1', + default: '', + description: 'Comma-separated list of allowed IP addresses. Leave empty to allow all IPs.', + }, { displayName: 'No Response Body', name: 'noResponseBody', @@ -368,3 +389,80 @@ export const optionsProperty: INodeProperties = { }, ], }; + +export const responseCodeSelector: INodeProperties = { + displayName: 'Response Code', + name: 'responseCode', + type: 'options', + options: [ + { name: '200', value: 200, description: 'OK - Request has succeeded' }, + { name: '201', value: 201, description: 'Created - Request has been fulfilled' }, + { name: '204', value: 204, description: 'No Content - Request processed, no content returned' }, + { + name: '301', + value: 301, + description: 'Moved Permanently - Requested resource moved permanently', + }, + { name: '302', value: 302, description: 'Found - Requested resource moved temporarily' }, + { name: '304', value: 304, description: 'Not Modified - Resource has not been modified' }, + { name: '400', value: 400, description: 'Bad Request - Request could not be understood' }, + { name: '401', value: 401, description: 'Unauthorized - Request requires user authentication' }, + { + name: '403', + value: 403, + description: 'Forbidden - Server understood, but refuses to fulfill', + }, + { name: '404', value: 404, description: 'Not Found - Server has not found a match' }, + { + name: 'Custom Code', + value: 'customCode', + description: 'Write any HTTP code', + }, + ], + default: 200, + description: 'The HTTP response code to return', +}; + +export const responseCodeOption: INodeProperties = { + displayName: 'Response Code', + name: 'responseCode', + placeholder: 'Add Response Code', + type: 'fixedCollection', + default: { + values: { + responseCode: 200, + }, + }, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + responseCodeSelector, + { + displayName: 'Code', + name: 'customCode', + type: 'number', + default: 200, + placeholder: 'e.g. 400', + typeOptions: { + minValue: 100, + }, + displayOptions: { + show: { + responseCode: ['customCode'], + }, + }, + }, + ], + }, + ], + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 2 } }], + }, + hide: { + '/responseMode': ['responseNode'], + }, + }, +}; diff --git a/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts b/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts index 246f7dae1e..97fd23e90e 100644 --- a/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts +++ b/packages/nodes-base/nodes/Webhook/test/Webhook.test.ts @@ -15,6 +15,10 @@ describe('Test Webhook Node', () => { nodeHelpers: mock(), }); context.getNodeParameter.calledWith('options').mockReturnValue({}); + context.getNode.calledWith().mockReturnValue({ + type: 'n8n-nodes-base.webhook', + typeVersion: 1.1, + } as any); const req = mock(); req.contentType = 'multipart/form-data'; context.getRequestObject.mockReturnValue(req); diff --git a/packages/nodes-base/nodes/Webhook/utils.ts b/packages/nodes-base/nodes/Webhook/utils.ts new file mode 100644 index 0000000000..1bf6b8407e --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/utils.ts @@ -0,0 +1,141 @@ +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import type { IWebhookFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; + +type WebhookParameters = { + httpMethod: string; + responseMode: string; + responseData: string; + responseCode?: number; //typeVersion <= 1.1 + options?: { + responseData?: string; + responseCode?: { + values?: { + responseCode: number; + customCode?: number; + }; + }; + noResponseBody?: boolean; + }; +}; + +export const getResponseCode = (parameters: WebhookParameters) => { + if (parameters.responseCode) { + return parameters.responseCode; + } + const responseCodeOptions = parameters.options; + if (responseCodeOptions?.responseCode?.values) { + const { responseCode, customCode } = responseCodeOptions.responseCode.values; + + if (customCode) { + return customCode; + } + + return responseCode; + } + return 200; +}; + +export const getResponseData = (parameters: WebhookParameters) => { + const { responseData, responseMode, options } = parameters; + if (responseData) return responseData; + + if (responseMode === 'onReceived') { + const data = options?.responseData; + if (data) return data; + } + + if (options?.noResponseBody) return 'noData'; + + return undefined; +}; + +export const configuredOutputs = (parameters: WebhookParameters) => { + const httpMethod = parameters.httpMethod; + + return [ + { + type: `${NodeConnectionType.Main}`, + displayName: httpMethod, + }, + ]; +}; + +export const setupOutputConnection = ( + ctx: IWebhookFunctions, + additionalData: { + jwtPayload?: IDataObject; + }, +) => { + let webhookUrl = ctx.getNodeWebhookUrl('default') as string; + const executionMode = ctx.getMode() === 'manual' ? 'test' : 'production'; + + if (executionMode === 'test') { + webhookUrl = webhookUrl.replace('/webhook/', '/webhook-test/'); + } + + return (outputData: INodeExecutionData): INodeExecutionData[][] => { + outputData.json.webhookUrl = webhookUrl; + outputData.json.executionMode = executionMode; + if (additionalData?.jwtPayload) { + outputData.json.jwtPayload = additionalData.jwtPayload; + } + return [[outputData]]; + }; +}; + +export const isIpWhitelisted = ( + whitelist: string | string[] | undefined, + ips: string[], + ip?: string, +) => { + if (whitelist === undefined || whitelist === '') { + return true; + } + + if (!Array.isArray(whitelist)) { + whitelist = whitelist.split(',').map((entry) => entry.trim()); + } + + for (const address of whitelist) { + if (ip && ip.includes(address)) { + return true; + } + + if (ips.some((entry) => entry.includes(address))) { + return true; + } + } + + return false; +}; + +export const checkResponseModeConfiguration = (context: IWebhookFunctions) => { + const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string; + const connectedNodes = context.getChildNodes(context.getNode().name); + + const isRespondToWebhookConnected = connectedNodes.some( + (node) => node.type === 'n8n-nodes-base.respondToWebhook', + ); + + if (!isRespondToWebhookConnected && responseMode === 'responseNode') { + throw new NodeOperationError( + context.getNode(), + new Error('No Respond to Webhook node found in the workflow'), + { + description: + 'Insert a Respond to Webhook node to your workflow to respond to the webhook or choose another option for the “Respond” parameter', + }, + ); + } + + if (isRespondToWebhookConnected && responseMode !== 'responseNode') { + throw new NodeOperationError( + context.getNode(), + new Error('Webhook node not correctly configured'), + { + description: + 'Set the “Respond” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node', + }, + ); + } +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 328e98ad93..12388ea86d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -184,6 +184,7 @@ "dist/credentials/JiraSoftwareCloudApi.credentials.js", "dist/credentials/JiraSoftwareServerApi.credentials.js", "dist/credentials/JotFormApi.credentials.js", + "dist/credentials/jwtAuth.credentials.js", "dist/credentials/Kafka.credentials.js", "dist/credentials/KeapOAuth2Api.credentials.js", "dist/credentials/KibanaApi.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 2c36fbb101..934922d1dd 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -813,6 +813,12 @@ export interface RequestHelperFunctions { ): Promise; } +export type NodeTypeAndVersion = { + name: string; + type: string; + typeVersion: number; +}; + export interface FunctionsBase { logger: Logger; getCredentials(type: string, itemIndex?: number): Promise; @@ -824,7 +830,8 @@ export interface FunctionsBase { getRestApiUrl(): string; getInstanceBaseUrl(): string; getInstanceId(): string; - + getChildNodes(nodeName: string): NodeTypeAndVersion[]; + getParentNodes(nodeName: string): NodeTypeAndVersion[]; getMode?: () => WorkflowExecuteMode; getActivationMode?: () => WorkflowActivateMode; diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index af2ed7c094..101dd20e1b 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -264,7 +264,8 @@ const commonCORSParameters: INodeProperties[] = [ name: 'allowedOrigins', type: 'string', default: '*', - description: 'The origin(s) to allow cross-origin non-preflight requests from in a browser', + description: + 'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.', }, ]; @@ -278,7 +279,11 @@ export function applySpecialNodeParameters(nodeType: INodeType): void { } if (nodeType.webhook && supportsCORS) { const optionsProperty = properties.find(({ name }) => name === 'options'); - if (optionsProperty) optionsProperty.options!.push(...commonCORSParameters); + if (optionsProperty) + optionsProperty.options = [ + ...commonCORSParameters, + ...(optionsProperty.options as INodePropertyOptions[]), + ]; else properties.push(...commonCORSParameters); } } @@ -533,7 +538,7 @@ export function getParameterResolveOrder( parameterDependencies: IParameterDependencies, ): number[] { const executionOrder: number[] = []; - const indexToResolve = Array.from({ length: nodePropertiesArray.length }, (v, k) => k); + const indexToResolve = Array.from({ length: nodePropertiesArray.length }, (_, k) => k); const resolvedParameters: string[] = []; let index: number; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 401c3dedab..33d6ac78f3 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -1332,6 +1332,12 @@ export class Workflow { // The node did already fail. So throw an error here that it displays and logs it correctly. // Does get used by webhook and trigger nodes in case they throw an error that it is possible // to log the error and display in Editor-UI. + if ( + runExecutionData.resultData.error.name === 'NodeOperationError' || + runExecutionData.resultData.error.name === 'NodeApiError' + ) { + throw runExecutionData.resultData.error; + } const error = new Error(runExecutionData.resultData.error.message); error.stack = runExecutionData.resultData.error.stack;