feat(Webhook Node): Overhaul (#8889)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Michael Kret 2024-03-28 10:46:39 +02:00 committed by GitHub
parent 519f945547
commit e84c27c0ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 780 additions and 43 deletions

View file

@ -48,11 +48,10 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
} }
if (responseCode) { if (responseCode) {
cy.getByTestId('parameter-input-responseCode') cy.get('.param-options').click();
.find('.parameter-input') getVisibleSelect().contains('Response Code').click();
.find('input') cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
.clear() getVisibleSelect().contains('201').click();
.type(responseCode.toString());
} }
if (respondWith) { if (respondWith) {

View file

@ -115,12 +115,12 @@ export abstract class AbstractServer {
private async setupHealthCheck() { private async setupHealthCheck() {
// health check should not care about DB connections // 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' }); res.send({ status: 'ok' });
}); });
const { connectionState } = Db; const { connectionState } = Db;
this.app.use((req, res, next) => { this.app.use((_req, res, next) => {
if (connectionState.connected) { if (connectionState.connected) {
if (connectionState.migrated) next(); if (connectionState.migrated) next();
else res.send('n8n is starting up. Please wait'); else res.send('n8n is starting up. Please wait');

View file

@ -106,10 +106,22 @@ export const webhookRequestHandler =
const options = await webhookManager.findAccessControlOptions(path, requestedMethod); const options = await webhookManager.findAccessControlOptions(path, requestedMethod);
const { allowedOrigins } = options ?? {}; const { allowedOrigins } = options ?? {};
res.header( if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) {
'Access-Control-Allow-Origin', const originsList = allowedOrigins.split(',');
!allowedOrigins || allowedOrigins === '*' ? req.headers.origin : allowedOrigins, 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') { if (method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '300'); res.header('Access-Control-Max-Age', '300');
@ -262,14 +274,14 @@ export async function executeWebhook(
); );
const responseCode = workflow.expression.getSimpleParameterValue( const responseCode = workflow.expression.getSimpleParameterValue(
workflowStartNode, workflowStartNode,
webhookData.webhookDescription.responseCode, webhookData.webhookDescription.responseCode as string,
executionMode, executionMode,
additionalKeys, additionalKeys,
undefined, undefined,
200, 200,
) as number; ) as number;
const responseData = workflow.expression.getSimpleParameterValue( const responseData = workflow.expression.getComplexParameterValue(
workflowStartNode, workflowStartNode,
webhookData.webhookDescription.responseData, webhookData.webhookDescription.responseData,
executionMode, executionMode,
@ -324,7 +336,7 @@ export async function executeWebhook(
// TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly // TODO: pass a custom `fileWriteStreamHandler` to create binary data files directly
}); });
req.body = await new Promise((resolve) => { req.body = await new Promise((resolve) => {
form.parse(req, async (err, data, files) => { form.parse(req, async (_err, data, files) => {
normalizeFormData(data); normalizeFormData(data);
normalizeFormData(files); normalizeFormData(files);
resolve({ data, files }); resolve({ data, files });
@ -455,6 +467,12 @@ export async function executeWebhook(
responseCallback(null, { responseCallback(null, {
responseCode, 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) { } else if (webhookResultData.webhookResponse !== undefined) {
// Data to respond with is given // Data to respond with is given
responseCallback(null, { responseCallback(null, {

View file

@ -49,7 +49,7 @@ export class WebhookNotFoundError extends NotFoundError {
const hintMsg = const hintMsg =
hint === 'default' 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)"; : "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); super(errorMsg, hintMsg);

View file

@ -1,6 +1,6 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { CacheService } from '@/services/cache/cache.service'; 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 type { IWorkflowDb } from '@/Interfaces';
import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants';

View file

@ -92,6 +92,7 @@ import type {
NodeExecutionWithMetadata, NodeExecutionWithMetadata,
NodeHelperFunctions, NodeHelperFunctions,
NodeParameterValueType, NodeParameterValueType,
NodeTypeAndVersion,
PaginationOptions, PaginationOptions,
RequestHelperFunctions, RequestHelperFunctions,
Workflow, Workflow,
@ -2798,7 +2799,34 @@ const getCommonWorkflowFunctions = (
active: workflow.active, active: workflow.active,
}), }),
getWorkflowStaticData: (type) => workflow.getStaticData(type, node), 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, getRestApiUrl: () => additionalData.restApiUrl,
getInstanceBaseUrl: () => additionalData.instanceBaseUrl, getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
getInstanceId: () => Container.get(InstanceSettings).instanceId, getInstanceId: () => Container.get(InstanceSettings).instanceId,

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="200px"
height="200px"
viewBox="0 0 200 200"
version="1.1"
id="svg10"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
<title
id="title1">Group</title>
<desc
id="desc1">Created with Sketch.</desc>
<defs
id="defs1">
<filter
style="color-interpolation-filters:sRGB"
id="filter11"
x="-0.18346753"
y="-0.076523014"
width="1.3669351"
height="1.153046">
<feGaussianBlur
stdDeviation="1.2230926"
id="feGaussianBlur11" />
</filter>
<filter
style="color-interpolation-filters:sRGB"
id="filter12"
x="-0.18346753"
y="-0.076523014"
width="1.3669351"
height="1.153046">
<feGaussianBlur
stdDeviation="1.2230926"
id="feGaussianBlur12" />
</filter>
</defs>
<g
id="layer1" />
<g
transform="translate(57.626456,48.948311)"
id="Shape-6">
<g
id="g12"
transform="matrix(1.8791546,0,0,1.8791546,-51.584186,-42.862227)">
<path
d="M 42.223544,27.435493 V -0.303311 H 58.223226 V 27.435493 L 50.223385,38.05668 Z"
fill="#ffffff"
id="path11"
style="fill:#ececec;stroke-width:1.04876;filter:url(#filter12)" />
<path
d="M 57.5,26.9 V 0 h -15 V 26.9 L 50,37.2 Z"
fill="#ffffff"
id="path1-7" />
<path
d="M 42.223544,72.517876 V 100.25668 H 58.223226 V 72.517876 L 50.223385,61.896689 Z"
fill="#ffffff"
id="path10"
style="fill:#ececec;stroke-width:1.04876;filter:url(#filter11)" />
<path
d="m 42.623319,73.156936 v 26.900004 h 15 V 73.156936 l -7.5,-10.3 z"
fill="#ffffff"
id="path2-5" />
<path
d="M 57.5,73.1 73.3,94.9 85.5,86 69.6,64.3 57.5,60.3 Z"
fill="#00f2e6"
id="path3-3" />
<path
d="M 42.5,26.9 26.7,5.1 14.5,14 l 15.9,21.7 12.1,4 z"
fill="#00f2e6"
id="path4-5" />
<path
d="M 30.4,35.7 4.8,27.4 0.1,41.7 25.7,50 37.9,46.1 Z"
fill="#00b9f1"
id="path5-6" />
<path
d="M 62.1,53.9 69.6,64.3 95.2,72.6 99.9,58.3 74.3,50 Z"
fill="#00b9f1"
id="path6-2" />
<path
d="M 74.3,50 99.9,41.7 95.2,27.4 69.6,35.7 62.1,46.1 Z"
fill="#d63aff"
id="path7-9" />
<path
d="M 25.7,50 0.1,58.3 4.8,72.6 30.4,64.3 37.9,53.9 Z"
fill="#d63aff"
id="path8-1" />
<path
d="M 30.4,64.3 14.5,86 26.7,94.9 42.5,73.1 V 60.3 Z"
fill="#fb015b"
id="path9-2" />
<path
d="M 69.6,35.7 85.5,14 73.3,5.1 57.5,26.9 v 12.8 z"
fill="#fb015b"
id="path10-7" />
</g>
</g>
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Group</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

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

View file

@ -10,6 +10,8 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { jsonParse, BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; import { jsonParse, BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import set from 'lodash/set'; import set from 'lodash/set';
import jwt from 'jsonwebtoken';
import { formatPrivateKey } from '../../utils/utilities';
export class RespondToWebhook implements INodeType { export class RespondToWebhook implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -17,14 +19,24 @@ export class RespondToWebhook implements INodeType {
icon: 'file:webhook.svg', icon: 'file:webhook.svg',
name: 'respondToWebhook', name: 'respondToWebhook',
group: ['transform'], group: ['transform'],
version: 1, version: [1, 1.1],
description: 'Returns data for Webhook', description: 'Returns data for Webhook',
defaults: { defaults: {
name: 'Respond to Webhook', name: 'Respond to Webhook',
}, },
inputs: ['main'], inputs: ['main'],
outputs: ['main'], outputs: ['main'],
credentials: [], credentials: [
{
name: 'jwtAuth',
required: true,
displayOptions: {
show: {
respondWith: ['jwt'],
},
},
},
],
properties: [ properties: [
{ {
displayName: displayName:
@ -58,6 +70,11 @@ export class RespondToWebhook implements INodeType {
value: 'json', value: 'json',
description: 'Respond with a custom JSON body', description: 'Respond with a custom JSON body',
}, },
{
name: 'JWT Token',
value: 'jwt',
description: 'Respond with a JWT token',
},
{ {
name: 'No Data', name: 'No Data',
value: 'noData', value: 'noData',
@ -77,6 +94,17 @@ export class RespondToWebhook implements INodeType {
default: 'firstIncomingItem', default: 'firstIncomingItem',
description: 'The data that should be returned', description: 'The data that should be returned',
}, },
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
displayOptions: {
show: {
respondWith: ['jwt'],
},
},
},
{ {
displayName: displayName:
'When using expressions, note that this node will only run for the first item in the input data', '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', type: 'notice',
displayOptions: { displayOptions: {
show: { show: {
respondWith: ['json', 'text'], respondWith: ['json', 'text', 'jwt'],
}, },
}, },
default: '', default: '',
@ -119,6 +147,22 @@ export class RespondToWebhook implements INodeType {
}, },
description: 'The HTTP response JSON data', 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', displayName: 'Response Body',
name: 'responseBody', name: 'responseBody',
@ -243,6 +287,21 @@ export class RespondToWebhook implements INodeType {
}; };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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 items = this.getInputData();
const respondWith = this.getNodeParameter('respondWith', 0) as string; 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') { } else if (respondWith === 'allIncomingItems') {
const respondItems = items.map((item) => item.json); const respondItems = items.map((item) => item.json);
responseBody = options.responseKey responseBody = options.responseKey

View file

@ -10,6 +10,7 @@ import type {
INodeTypeDescription, INodeTypeDescription,
IWebhookResponseData, IWebhookResponseData,
MultiPartFormData, MultiPartFormData,
INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError, Node } 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 basicAuth from 'basic-auth';
import isbot from 'isbot'; import isbot from 'isbot';
import { file as tmpFile } from 'tmp-promise'; import { file as tmpFile } from 'tmp-promise';
import jwt from 'jsonwebtoken';
import { import {
authenticationProperty, authenticationProperty,
@ -25,11 +27,19 @@ import {
httpMethodsProperty, httpMethodsProperty,
optionsProperty, optionsProperty,
responseBinaryPropertyNameProperty, responseBinaryPropertyNameProperty,
responseCodeOption,
responseCodeProperty, responseCodeProperty,
responseDataProperty, responseDataProperty,
responseModeProperty, responseModeProperty,
} from './description'; } from './description';
import { WebhookAuthorizationError } from './error'; import { WebhookAuthorizationError } from './error';
import {
checkResponseModeConfiguration,
configuredOutputs,
isIpWhitelisted,
setupOutputConnection,
} from './utils';
import { formatPrivateKey } from '../../utils/utilities';
export class Webhook extends Node { export class Webhook extends Node {
authPropertyName = 'authentication'; authPropertyName = 'authentication';
@ -39,7 +49,7 @@ export class Webhook extends Node {
icon: 'file:webhook.svg', icon: 'file:webhook.svg',
name: 'webhook', name: 'webhook',
group: ['trigger'], group: ['trigger'],
version: [1, 1.1], version: [1, 1.1, 2],
description: 'Starts the workflow when a webhook is called', description: 'Starts the workflow when a webhook is called',
eventTriggerDescription: 'Waiting for you to call the Test URL', eventTriggerDescription: 'Waiting for you to call the Test URL',
activationMessage: 'You can now make calls to your production webhook 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. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key="executions">executions list</a>, but not in the editor.', 'Webhooks have two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the \'listen\' button, then make a request to the test URL. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Since the workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key="executions">executions list</a>, but not in the editor.',
}, },
activationHint: activationHint:
'Once youve 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 // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [], inputs: [],
outputs: ['main'], outputs: `={{(${configuredOutputs})($parameter)}}`,
credentials: credentialsProperty(this.authPropertyName), credentials: credentialsProperty(this.authPropertyName),
webhooks: [defaultWebhookDescription], webhooks: [defaultWebhookDescription],
properties: [ properties: [
authenticationProperty(this.authPropertyName),
httpMethodsProperty, httpMethodsProperty,
{ {
displayName: 'Path', displayName: 'Path',
@ -73,8 +82,10 @@ export class Webhook extends Node {
default: '', default: '',
placeholder: 'webhook', placeholder: 'webhook',
required: true, 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, responseModeProperty,
{ {
displayName: displayName:
@ -88,27 +99,63 @@ export class Webhook extends Node {
}, },
default: '', default: '',
}, },
responseCodeProperty, {
...responseCodeProperty,
displayOptions: {
show: {
'@version': [1, 1.1],
},
hide: {
responseMode: ['responseNode'],
},
},
},
responseDataProperty, responseDataProperty,
responseBinaryPropertyNameProperty, 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<IWebhookResponseData> { async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
const { typeVersion: nodeVersion, type: nodeType } = context.getNode();
if (nodeVersion >= 2 && nodeType === 'n8n-nodes-base.webhook') {
checkResponseModeConfiguration(context);
}
const options = context.getNodeParameter('options', {}) as { const options = context.getNodeParameter('options', {}) as {
binaryData: boolean; binaryData: boolean;
ignoreBots: boolean; ignoreBots: boolean;
rawBody: boolean; rawBody: boolean;
responseData?: string; responseData?: string;
ipWhitelist?: string;
}; };
const req = context.getRequestObject(); const req = context.getRequestObject();
const resp = context.getResponseObject(); 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 { try {
if (options.ignoreBots && isbot(req.headers['user-agent'])) if (options.ignoreBots && isbot(req.headers['user-agent']))
throw new WebhookAuthorizationError(403); throw new WebhookAuthorizationError(403);
await this.validateAuth(context); validationData = await this.validateAuth(context);
} catch (error) { } catch (error) {
if (error instanceof WebhookAuthorizationError) { if (error instanceof WebhookAuthorizationError) {
resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' }); resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' });
@ -118,18 +165,21 @@ export class Webhook extends Node {
throw error; throw error;
} }
const prepareOutput = setupOutputConnection(context, {
jwtPayload: validationData,
});
if (options.binaryData) { if (options.binaryData) {
return await this.handleBinaryData(context); return await this.handleBinaryData(context, prepareOutput);
} }
if (req.contentType === 'multipart/form-data') { 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) { if (nodeVersion > 1 && !req.body && !options.rawBody) {
try { try {
return await this.handleBinaryData(context); return await this.handleBinaryData(context, prepareOutput);
} catch (error) {} } catch (error) {}
} }
@ -156,7 +206,7 @@ export class Webhook extends Node {
return { return {
webhookResponse: options.responseData, webhookResponse: options.responseData,
workflowData: [[response]], workflowData: prepareOutput(response),
}; };
} }
@ -208,10 +258,52 @@ export class Webhook extends Node {
// Provided authentication data is wrong // Provided authentication data is wrong
throw new WebhookAuthorizationError(403); 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 req = context.getRequestObject() as MultiPartFormData.Request;
const options = context.getNodeParameter('options', {}) as IDataObject; const options = context.getNodeParameter('options', {}) as IDataObject;
const { data, files } = req.body; 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<IWebhookResponseData> { private async handleBinaryData(
context: IWebhookFunctions,
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
): Promise<IWebhookResponseData> {
const req = context.getRequestObject(); const req = context.getRequestObject();
const options = context.getNodeParameter('options', {}) as IDataObject; const options = context.getNodeParameter('options', {}) as IDataObject;
@ -298,7 +393,7 @@ export class Webhook extends Node {
returnItem.binary = { [binaryPropertyName]: binaryData }; returnItem.binary = { [binaryPropertyName]: binaryData };
} }
return { workflowData: [[returnItem]] }; return { workflowData: prepareOutput(returnItem) };
} catch (error) { } catch (error) {
throw new NodeOperationError(context.getNode(), error as Error); throw new NodeOperationError(context.getNode(), error as Error);
} finally { } finally {

View file

@ -1,13 +1,13 @@
import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow'; import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
import { getResponseCode, getResponseData } from './utils';
export const defaultWebhookDescription: IWebhookDescription = { export const defaultWebhookDescription: IWebhookDescription = {
name: 'default', name: 'default',
httpMethod: '={{$parameter["httpMethod"] || "GET"}}', httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
isFullPath: true, isFullPath: true,
responseCode: '={{$parameter["responseCode"]}}', responseCode: `={{(${getResponseCode})($parameter)}}`,
responseMode: '={{$parameter["responseMode"]}}', responseMode: '={{$parameter["responseMode"]}}',
responseData: responseData: `={{(${getResponseData})($parameter)}}`,
'={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}',
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
responseContentType: '={{$parameter["options"]["responseContentType"]}}', responseContentType: '={{$parameter["options"]["responseContentType"]}}',
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', 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 => ({ export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({
@ -51,6 +60,10 @@ export const authenticationProperty = (propertyName = 'authentication'): INodePr
name: 'Header Auth', name: 'Header Auth',
value: 'headerAuth', value: 'headerAuth',
}, },
{
name: 'JWT Auth',
value: 'jwtAuth',
},
{ {
name: 'None', name: 'None',
value: 'none', value: 'none',
@ -243,6 +256,14 @@ export const optionsProperty: INodeProperties = {
default: false, default: false,
description: 'Whether to ignore requests from bots like link previewers and web crawlers', 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', displayName: 'No Response Body',
name: 'noResponseBody', 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'],
},
},
};

View file

@ -15,6 +15,10 @@ describe('Test Webhook Node', () => {
nodeHelpers: mock(), nodeHelpers: mock(),
}); });
context.getNodeParameter.calledWith('options').mockReturnValue({}); context.getNodeParameter.calledWith('options').mockReturnValue({});
context.getNode.calledWith().mockReturnValue({
type: 'n8n-nodes-base.webhook',
typeVersion: 1.1,
} as any);
const req = mock<Request>(); const req = mock<Request>();
req.contentType = 'multipart/form-data'; req.contentType = 'multipart/form-data';
context.getRequestObject.mockReturnValue(req); context.getRequestObject.mockReturnValue(req);

View file

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

View file

@ -184,6 +184,7 @@
"dist/credentials/JiraSoftwareCloudApi.credentials.js", "dist/credentials/JiraSoftwareCloudApi.credentials.js",
"dist/credentials/JiraSoftwareServerApi.credentials.js", "dist/credentials/JiraSoftwareServerApi.credentials.js",
"dist/credentials/JotFormApi.credentials.js", "dist/credentials/JotFormApi.credentials.js",
"dist/credentials/jwtAuth.credentials.js",
"dist/credentials/Kafka.credentials.js", "dist/credentials/Kafka.credentials.js",
"dist/credentials/KeapOAuth2Api.credentials.js", "dist/credentials/KeapOAuth2Api.credentials.js",
"dist/credentials/KibanaApi.credentials.js", "dist/credentials/KibanaApi.credentials.js",

View file

@ -813,6 +813,12 @@ export interface RequestHelperFunctions {
): Promise<any>; ): Promise<any>;
} }
export type NodeTypeAndVersion = {
name: string;
type: string;
typeVersion: number;
};
export interface FunctionsBase { export interface FunctionsBase {
logger: Logger; logger: Logger;
getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>; getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>;
@ -824,7 +830,8 @@ export interface FunctionsBase {
getRestApiUrl(): string; getRestApiUrl(): string;
getInstanceBaseUrl(): string; getInstanceBaseUrl(): string;
getInstanceId(): string; getInstanceId(): string;
getChildNodes(nodeName: string): NodeTypeAndVersion[];
getParentNodes(nodeName: string): NodeTypeAndVersion[];
getMode?: () => WorkflowExecuteMode; getMode?: () => WorkflowExecuteMode;
getActivationMode?: () => WorkflowActivateMode; getActivationMode?: () => WorkflowActivateMode;

View file

@ -264,7 +264,8 @@ const commonCORSParameters: INodeProperties[] = [
name: 'allowedOrigins', name: 'allowedOrigins',
type: 'string', type: 'string',
default: '*', 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) { if (nodeType.webhook && supportsCORS) {
const optionsProperty = properties.find(({ name }) => name === 'options'); 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); else properties.push(...commonCORSParameters);
} }
} }
@ -533,7 +538,7 @@ export function getParameterResolveOrder(
parameterDependencies: IParameterDependencies, parameterDependencies: IParameterDependencies,
): number[] { ): number[] {
const executionOrder: 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[] = []; const resolvedParameters: string[] = [];
let index: number; let index: number;

View file

@ -1332,6 +1332,12 @@ export class Workflow {
// The node did already fail. So throw an error here that it displays and logs it correctly. // 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 // 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. // 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); const error = new Error(runExecutionData.resultData.error.message);
error.stack = runExecutionData.resultData.error.stack; error.stack = runExecutionData.resultData.error.stack;