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 {

View File

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

View File

@@ -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<Request>();
req.contentType = 'multipart/form-data';
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',
},
);
}
};