mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(Webhook Node): Overhaul (#8889)
Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
@@ -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 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<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 {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
141
packages/nodes-base/nodes/Webhook/utils.ts
Normal file
141
packages/nodes-base/nodes/Webhook/utils.ts
Normal 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',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user