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
17 changed files with 780 additions and 43 deletions

View File

@@ -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. <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:
'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
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<IWebhookResponseData> {
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<IWebhookResponseData> {
private async handleBinaryData(
context: IWebhookFunctions,
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
): Promise<IWebhookResponseData> {
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 {