feat: Add token to sendAndWait operations links to walidate in webhook (#17566)

This commit is contained in:
Michael Kret
2025-08-06 17:28:50 +03:00
committed by GitHub
parent 6495e08c79
commit 9cb5754f33
30 changed files with 277 additions and 92 deletions

View File

@@ -1,7 +1,7 @@
import { Logger } from '@n8n/backend-common';
import type { IExecutionResponse } from '@n8n/db';
import { ExecutionRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Container, Service } from '@n8n/di';
import type express from 'express';
import {
FORM_NODE_TYPE,
@@ -25,6 +25,13 @@ import type {
IWebhookManager,
WaitingWebhookRequest,
} from './webhook.types';
import {
InstanceSettings,
WAITING_TOKEN_QUERY_PARAM,
prepareUrlForSigning,
generateUrlSignature,
} from 'n8n-core';
import crypto from 'crypto';
/**
* Service for handling the execution of webhooks of Wait nodes that use the
@@ -82,6 +89,30 @@ export class WaitingWebhooks implements IWebhookManager {
});
}
private getHmacSecret() {
return Container.get(InstanceSettings).hmacSignatureSecret;
}
private validateSignatureInRequest(req: express.Request, secret: string) {
try {
const actualToken = req.query[WAITING_TOKEN_QUERY_PARAM];
if (typeof actualToken !== 'string') return false;
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
parsedUrl.searchParams.delete(WAITING_TOKEN_QUERY_PARAM);
const urlForSigning = prepareUrlForSigning(parsedUrl);
const expectedToken = generateUrlSignature(urlForSigning, secret);
const valid = crypto.timingSafeEqual(Buffer.from(actualToken), Buffer.from(expectedToken));
return valid;
} catch (error) {
return false;
}
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
@@ -97,6 +128,17 @@ export class WaitingWebhooks implements IWebhookManager {
const execution = await this.getExecution(executionId);
if (execution && execution.data.validateSignature) {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
const lastNode = execution.workflowData.nodes.find((node) => node.name === lastNodeExecuted);
const shouldValidate = lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION;
if (shouldValidate && !this.validateSignatureInRequest(req, this.getHmacSecret())) {
res.status(401).json({ error: 'Invalid token' });
return { noWebhookResponse: true };
}
}
if (!execution) {
throw new NotFoundError(`The execution "${executionId}" does not exist.`);
}

View File

@@ -210,6 +210,8 @@ export class NodeTestHarness {
hooks.addHandler('workflowExecuteAfter', (fullRunData) => waitPromise.resolve(fullRunData));
const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: '1',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
hooks,
// Get from node.parameters
currentNodeParameters: undefined,

View File

@@ -19,3 +19,5 @@ export const CREDENTIAL_ERRORS = {
INVALID_JSON: 'Decrypted credentials data is not valid JSON.',
INVALID_DATA: 'Credentials data is not in a valid format.',
};
export const WAITING_TOKEN_QUERY_PARAM = 'signature';

View File

@@ -19,7 +19,11 @@ import { NodeExecutionContext } from '../node-execution-context';
class TestContext extends NodeExecutionContext {}
describe('NodeExecutionContext', () => {
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
const instanceSettings = mock<InstanceSettings>({
instanceId: 'abc123',
encryptionKey: 'testEncryptionKey',
hmacSignatureSecret: 'testHmacSignatureSecret',
});
Container.set(InstanceSettings, instanceSettings);
const node = mock<INode>();
@@ -363,4 +367,41 @@ describe('NodeExecutionContext', () => {
expect(result).toEqual([node1]);
});
});
describe('getSignedResumeUrl', () => {
beforeEach(() => {
jest.clearAllMocks();
testContext = new TestContext(
workflow,
mock<INode>({
id: 'node456',
}),
mock<IWorkflowExecuteAdditionalData>({
executionId: '123',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
}),
mode,
{
validateSignature: true,
resultData: { runData: {} },
},
);
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
it('should return a signed resume URL with no query parameters', () => {
const result = testContext.getSignedResumeUrl();
expect(result).toBe(
'http://localhost/waiting-webhook/123/node456?signature=8e48dfd1107c1a736f70e7399493ffc50a2e8edd44f389c5f9c058da961682e7',
);
});
it('should return a signed resume URL with query parameters', () => {
const result = testContext.getSignedResumeUrl({ approved: 'true' });
expect(result).toBe(
'http://localhost/waiting-webhook/123/node456?approved=true&signature=11c5efc97a0d6f2ea9045dba6e397596cba29dc24adb44a9ebd3d1272c991e9b',
);
});
});
});

View File

@@ -30,12 +30,14 @@ import {
ExpressionError,
NodeHelpers,
NodeOperationError,
UnexpectedError,
} from 'n8n-workflow';
import {
HTTP_REQUEST_AS_TOOL_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
HTTP_REQUEST_TOOL_NODE_TYPE,
WAITING_TOKEN_QUERY_PARAM,
} from '@/constants';
import { InstanceSettings } from '@/instance-settings';
@@ -44,6 +46,7 @@ import { ensureType } from './utils/ensure-type';
import { extractValue } from './utils/extract-value';
import { getAdditionalKeys } from './utils/get-additional-keys';
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
import { generateUrlSignature, prepareUrlForSigning } from '../../utils/signature-helpers';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings);
@@ -200,6 +203,32 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.instanceSettings.instanceId;
}
setSignatureValidationRequired() {
if (this.runExecutionData) this.runExecutionData.validateSignature = true;
}
getSignedResumeUrl(parameters: Record<string, string> = {}) {
const { webhookWaitingBaseUrl, executionId } = this.additionalData;
if (typeof executionId !== 'string') {
throw new UnexpectedError('Execution id is missing');
}
const baseURL = new URL(`${webhookWaitingBaseUrl}/${executionId}/${this.node.id}`);
for (const [key, value] of Object.entries(parameters)) {
baseURL.searchParams.set(key, value);
}
const urlForSigning = prepareUrlForSigning(baseURL);
const token = generateUrlSignature(urlForSigning, this.instanceSettings.hmacSignatureSecret);
baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);
return baseURL.toString();
}
getTimezone() {
return this.workflow.timezone;
}

View File

@@ -24,7 +24,6 @@ import { getInputConnectionData } from './utils/get-input-connection-data';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getNodeWebhookUrl } from './utils/webhook-helper-functions';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];

View File

@@ -52,6 +52,8 @@ export class InstanceSettings {
*/
readonly instanceId: string;
readonly hmacSignatureSecret: string;
readonly instanceType: InstanceType;
constructor(
@@ -64,6 +66,7 @@ export class InstanceSettings {
this.hostId = `${this.instanceType}-${this.isDocker ? os.hostname() : nanoid()}`;
this.settings = this.loadOrCreate();
this.instanceId = this.generateInstanceId();
this.hmacSignatureSecret = this.getOrGenerateHmacSignatureSecret();
}
/**
@@ -225,6 +228,14 @@ export class InstanceSettings {
.digest('hex');
}
private getOrGenerateHmacSignatureSecret() {
const hmacSignatureSecretFromEnv = process.env.N8N_HMAC_SIGNATURE_SECRET;
if (hmacSignatureSecretFromEnv) return hmacSignatureSecretFromEnv;
const { encryptionKey } = this;
return createHash('sha256').update(`hmac-signature:${encryptionKey}`).digest('hex');
}
private save(settings: Settings) {
this.settings = settings;
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, '\t'), {

View File

@@ -0,0 +1,26 @@
import { generateUrlSignature } from '../signature-helpers';
describe('signature-helpers', () => {
const secret = 'test-secret';
const baseUrl = 'http://localhost:5678';
describe('generateUrlSignature', () => {
it('should generate a signature token', () => {
const url = `${baseUrl}/webhook/abc`;
const token = generateUrlSignature(url, secret);
expect(token).toBe('fe7f1e4c11f875b2d24681e0b28d0bfed6d66381af5b0ab9633da2202a895243');
});
it('should generate a different token for a different url', () => {
const url = `${baseUrl}/webhook/def`;
const token = generateUrlSignature(url, secret);
expect(token).toBe('ab8e72e7a0e47689596a6550283cbef9e2797b7370b0d6d99c89ee7c2394ea8f');
});
it('should generate a different token for a different secret', () => {
const url = `${baseUrl}/webhook/abc`;
const token = generateUrlSignature(url, 'different-secret');
expect(token).toBe('84a99b6950e12ffcf1fcf8e0fc0986c0c8a46df331932efd79b17e0c11801bd2');
});
});
});

View File

@@ -1 +1,2 @@
export * from './serialized-buffer';
export * from './signature-helpers';

View File

@@ -0,0 +1,16 @@
import crypto from 'crypto';
/**
* Generate signature token from url and secret
*/
export function generateUrlSignature(url: string, secret: string) {
const token = crypto.createHmac('sha256', secret).update(url).digest('hex');
return token;
}
/**
* Prepare url for signing
*/
export function prepareUrlForSigning(url: URL) {
return `${url.host}${url.pathname}${url.search}`;
}

View File

@@ -57,6 +57,10 @@ describe('Test DiscordV2, message => sendAndWait', () => {
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&token=abc',
);
const result = await discord.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);
@@ -74,7 +78,7 @@ describe('Test DiscordV2, message => sendAndWait', () => {
label: 'Approve',
style: 5,
type: 2,
url: 'http://localhost/waiting-webhook/nodeID?approved=true',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&token=abc',
},
],
type: 1,

View File

@@ -415,7 +415,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
type: 2,
style: 5,
label: option.label,
url: `${config.url}?approved=${option.value}`,
url: option.url,
};
}),
},

View File

@@ -44,8 +44,9 @@ describe('Test EmailSendV2, email => sendAndWait', () => {
//getSendAndWaitConfig
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // approvalOptions
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
@@ -61,7 +62,9 @@ describe('Test EmailSendV2, email => sendAndWait', () => {
expect(transporter.sendMail).toHaveBeenCalledWith({
from: 'from@mail.com',
html: expect.stringContaining('href="http://localhost/waiting-webhook/nodeID?approved=true"'),
html: expect.stringContaining(
'href="http://localhost/waiting-webhook/nodeID?approved=true&signature=abc"',
),
subject: 'my subject',
to: 'to@mail.com',
});

View File

@@ -30,7 +30,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
const config = getSendAndWaitConfig(this);
const buttons: string[] = [];
for (const option of config.options) {
buttons.push(createButton(config.url, option.label, option.value, option.style));
buttons.push(createButton(option.url, option.label, option.style));
}
let htmlBody: string;

View File

@@ -163,7 +163,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const config = getSendAndWaitConfig(context);
const buttons: string[] = config.options.map(
(option) => `*<${`${config.url}?approved=${option.value}`}|${option.label}>*`,
(option) => `*<${`${option.url}`}|${option.label}>*`,
);
let text = `${config.message}\n\n\n${buttons.join(' ')}`;

View File

@@ -39,8 +39,9 @@ describe('Test GoogleChat, message => sendAndWait', () => {
//getSendAndWaitConfig
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // approvalOptions
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
@@ -55,7 +56,7 @@ describe('Test GoogleChat, message => sendAndWait', () => {
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith('POST', '/v1/spaceID/messages', {
text: 'my message\n\n\n*<http://localhost/waiting-webhook/nodeID?approved=true|Approve>*\n\n_This_ _message_ _was_ _sent_ _automatically_ _with_ _<https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.googleChat_instanceId|n8n>_',
text: 'my message\n\n\n*<http://localhost/waiting-webhook/nodeID?approved=true&signature=abc|Approve>*\n\n_This_ _message_ _was_ _sent_ _automatically_ _with_ _<https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.googleChat_instanceId|n8n>_',
});
});
});

View File

@@ -46,8 +46,9 @@ describe('Test MicrosoftOutlookV2, message => sendAndWait', () => {
//getSendAndWaitConfig
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // approvalOptions
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
@@ -65,7 +66,7 @@ describe('Test MicrosoftOutlookV2, message => sendAndWait', () => {
message: {
body: {
content: expect.stringContaining(
'href="http://localhost/waiting-webhook/nodeID?approved=true"',
'href="http://localhost/waiting-webhook/nodeID?approved=true&signature=abc"',
),
contentType: 'html',
},

View File

@@ -34,7 +34,7 @@ export async function execute(this: IExecuteFunctions, index: number, items: INo
const config = getSendAndWaitConfig(this);
const buttons: string[] = [];
for (const option of config.options) {
buttons.push(createButton(config.url, option.label, option.value, option.style));
buttons.push(createButton(option.url, option.label, option.style));
}
let bodyContent: string;

View File

@@ -45,8 +45,9 @@ describe('Test MicrosoftTeamsV2, chatMessage => sendAndWait', () => {
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
const result = await microsoftTeamsV2.execute.call(mockExecuteFunctions);
@@ -60,7 +61,7 @@ describe('Test MicrosoftTeamsV2, chatMessage => sendAndWait', () => {
{
body: {
content:
'my message<br><br><a href="http://localhost/waiting-webhook/nodeID?approved=true">Approve</a><br><br><em>This message was sent automatically with <a href="https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.microsoftTeams_instanceId">n8n</a></em>',
'my message<br><br><a href="http://localhost/waiting-webhook/nodeID?approved=true&signature=abc">Approve</a><br><br><em>This message was sent automatically with <a href="https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.microsoftTeams_instanceId">n8n</a></em>',
contentType: 'html',
},
},

View File

@@ -23,9 +23,7 @@ export async function execute(this: IExecuteFunctions, i: number, instanceId: st
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const config = getSendAndWaitConfig(this);
const buttons = config.options.map(
(option) => `<a href="${config.url}?approved=${option.value}">${option.label}</a>`,
);
const buttons = config.options.map((option) => `<a href="${option.url}">${option.label}</a>`);
let content = `${config.message}<br><br>${buttons.join(' ')}`;

View File

@@ -301,7 +301,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
text: option.label,
emoji: true,
},
url: `${config.url}?approved=${option.value}`,
url: option.url,
};
}),
},

View File

@@ -9,6 +9,12 @@ describe('Slack Utility Functions', () => {
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockExecuteFunctions.getNode.mockReturnValue({ name: 'Slack', typeVersion: 1 } as any);
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
);
jest.clearAllMocks();
});
@@ -33,8 +39,6 @@ describe('Slack Utility Functions', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({});
expect(createSendAndWaitMessageBody(mockExecuteFunctions)).toEqual({
@@ -70,7 +74,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=true',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
type: 'actions',
@@ -86,8 +90,6 @@ describe('Slack Utility Functions', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ approvalType: 'double' });
expect(createSendAndWaitMessageBody(mockExecuteFunctions)).toEqual({
@@ -123,7 +125,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=false',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
},
{
@@ -134,7 +136,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=true',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
type: 'actions',
@@ -150,8 +152,6 @@ describe('Slack Utility Functions', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({});
mockExecuteFunctions.getNode.mockReturnValue({ name: 'Slack', typeVersion: 2.3 } as any);
@@ -187,7 +187,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=true',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
type: 'actions',
@@ -203,8 +203,6 @@ describe('Slack Utility Functions', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ approvalType: 'double' });
mockExecuteFunctions.getNode.mockReturnValue({ name: 'Slack', typeVersion: 2.3 } as any);
@@ -241,7 +239,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=false',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
},
{
@@ -252,7 +250,7 @@ describe('Slack Utility Functions', () => {
type: 'plain_text',
},
type: 'button',
url: 'localhost/node123?approved=true',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
type: 'actions',

View File

@@ -278,7 +278,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
config.options.map((option) => {
return {
text: option.label,
url: `${config.url}?approved=${option.value}`,
url: option.url,
};
}),
],

View File

@@ -42,8 +42,9 @@ describe('Test Telegram, message => sendAndWait', () => {
//getSendAndWaitConfig
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // approvalOptions
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
@@ -63,7 +64,12 @@ describe('Test Telegram, message => sendAndWait', () => {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Approve', url: 'http://localhost/waiting-webhook/nodeID?approved=true' }],
[
{
text: 'Approve',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
],
},
text: 'my message\n\n_This message was sent automatically with _[n8n](https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.telegram_instanceId)',

View File

@@ -113,7 +113,7 @@ export const createMessage = (
instanceId: string,
): IHttpRequestOptions => {
const buttons = sendAndWaitConfig.options.map((option) => {
return `*${option.label}:*\n_${sendAndWaitConfig.url}?approved=${option.value}_\n\n`;
return `*${option.label}:*\n_${option.url}_\n\n`;
});
let n8nAttribution: string = '';

View File

@@ -39,8 +39,9 @@ describe('Test WhatsApp Business Cloud, sendAndWait operation', () => {
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
const result = await whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions);
@@ -55,7 +56,7 @@ describe('Test WhatsApp Business Cloud, sendAndWait operation', () => {
body: {
messaging_product: 'whatsapp',
text: {
body: 'my message\n\n*Approve:*\n_http://localhost/waiting-webhook/nodeID?approved=true_\n\n',
body: 'my message\n\n*Approve:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n',
},
to: '22222',
type: 'text',

View File

@@ -28,10 +28,17 @@ describe('createMessage', () => {
const mockSendAndWaitConfig: SendAndWaitConfig = {
title: '',
message: 'Please approve an option:',
url: 'https://example.com/approve',
options: [
{ label: 'Yes', value: 'yes', style: 'primary' },
{ label: 'No', value: 'no', style: 'secondary' },
{
label: 'Yes',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
{
label: 'No',
style: 'secondary',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
},
],
};
@@ -55,8 +62,8 @@ describe('createMessage', () => {
text: {
body:
'Please approve an option:\n\n' +
'*Yes:*\n_https://example.com/approve?approved=yes_\n\n' +
'*No:*\n_https://example.com/approve?approved=no_\n\n',
'*Yes:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n' +
'*No:*\n_http://localhost/waiting-webhook/nodeID?approved=false&signature=abc_\n\n',
},
type: 'text',
to: recipientPhone,
@@ -68,12 +75,11 @@ describe('createMessage', () => {
const singleOptionConfig: SendAndWaitConfig = {
title: '',
message: 'Choose an option:',
url: 'https://example.com/approve',
options: [
{
label: 'Confirm',
value: 'confirm',
style: '',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
};
@@ -92,7 +98,7 @@ describe('createMessage', () => {
body: {
messaging_product: 'whatsapp',
text: {
body: 'Choose an option:\n\n*Confirm:*\n_https://example.com/approve?approved=confirm_\n\n',
body: 'Choose an option:\n\n*Confirm:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n',
},
type: 'text',
to: recipientPhone,

View File

@@ -62,25 +62,20 @@ describe('Send and Wait utils tests', () => {
return params[parameterName];
});
mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => {
const expressions: { [key: string]: string } = {
'{{ $execution?.resumeUrl }}': 'http://localhost',
'{{ $nodeId }}': 'testNodeId',
};
return expressions[expression];
});
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
const config = getSendAndWaitConfig(mockExecuteFunctions);
expect(config).toEqual({
appendAttribution: undefined,
title: 'Test subject',
message: 'Test message',
url: 'http://localhost/testNodeId',
options: [
{
label: 'Approve',
value: 'true',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
],
});
@@ -102,13 +97,12 @@ describe('Send and Wait utils tests', () => {
return params[parameterName];
});
mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => {
const expressions: { [key: string]: string } = {
'{{ $execution?.resumeUrl }}': 'http://localhost',
'{{ $nodeId }}': 'testNodeId',
};
return expressions[expression];
});
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
);
mockExecuteFunctions.getSignedResumeUrl.mockReturnValueOnce(
'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
);
const config = getSendAndWaitConfig(mockExecuteFunctions);
@@ -117,13 +111,13 @@ describe('Send and Wait utils tests', () => {
expect.arrayContaining([
{
label: 'Reject',
value: 'false',
style: 'secondary',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
},
{
label: 'Approve',
value: 'true',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
},
]),
);
@@ -146,13 +140,7 @@ describe('Send and Wait utils tests', () => {
return params[parameterName];
});
mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => {
const expressions: { [key: string]: string } = {
'{{ $execution?.resumeUrl }}': 'http://localhost',
'{{ $nodeId }}': 'testNodeId',
};
return expressions[expression];
});
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue('http://localhost/testNodeId');
});
it('should create a valid email object', () => {

View File

@@ -34,8 +34,7 @@ import { escapeHtml } from '../utilities';
export type SendAndWaitConfig = {
title: string;
message: string;
url: string;
options: Array<{ label: string; value: string; style: string }>;
options: Array<{ label: string; url: string; style: string }>;
appendAttribution?: boolean;
};
@@ -480,8 +479,6 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string);
const resumeUrl = context.evaluateExpression('{{ $execution?.resumeUrl }}', 0) as string;
const nodeId = context.evaluateExpression('{{ $nodeId }}', 0) as string;
const approvalOptions = context.getNodeParameter('approvalOptions.values', 0, {}) as {
approvalType?: 'single' | 'double';
approveLabel?: string;
@@ -495,18 +492,20 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
const config: SendAndWaitConfig = {
title: subject,
message,
url: `${resumeUrl}/${nodeId}`,
options: [],
appendAttribution: options?.appendAttribution as boolean,
};
const responseType = context.getNodeParameter('responseType', 0, 'approval') as string;
context.setSignatureValidationRequired();
const approvedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'true' });
if (responseType === 'freeText' || responseType === 'customForm') {
const label = context.getNodeParameter('options.messageButtonLabel', 0, 'Respond') as string;
config.options.push({
label,
value: 'true',
url: approvedSignedResumeUrl,
style: 'primary',
});
} else if (approvalOptions.approvalType === 'double') {
@@ -514,15 +513,16 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary';
const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove');
const buttonDisapprovalStyle = approvalOptions.buttonDisapprovalStyle || 'secondary';
const disapprovedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'false' });
config.options.push({
label: disapproveLabel,
value: 'false',
url: disapprovedSignedResumeUrl,
style: buttonDisapprovalStyle,
});
config.options.push({
label: approveLabel,
value: 'true',
url: approvedSignedResumeUrl,
style: buttonApprovalStyle,
});
} else {
@@ -530,7 +530,7 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
const style = approvalOptions.buttonApprovalStyle || 'primary';
config.options.push({
label,
value: 'true',
url: approvedSignedResumeUrl,
style,
});
}
@@ -538,12 +538,12 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
return config;
}
export function createButton(url: string, label: string, approved: string, style: string) {
export function createButton(url: string, label: string, style: string) {
let buttonStyle = BUTTON_STYLE_PRIMARY;
if (style === 'secondary') {
buttonStyle = BUTTON_STYLE_SECONDARY;
}
return `<a href="${url}?approved=${approved}" target="_blank" style="${buttonStyle}">${label}</a>`;
return `<a href="${url}" target="_blank" style="${buttonStyle}">${label}</a>`;
}
export function createEmail(context: IExecuteFunctions) {
@@ -560,7 +560,7 @@ export function createEmail(context: IExecuteFunctions) {
const buttons: string[] = [];
for (const option of config.options) {
buttons.push(createButton(config.url, option.label, option.value, option.style));
buttons.push(createButton(option.url, option.label, option.style));
}
let emailBody: string;
if (config.appendAttribution !== false) {

View File

@@ -889,6 +889,10 @@ export interface FunctionsBase {
getRestApiUrl(): string;
getInstanceBaseUrl(): string;
getInstanceId(): string;
/** Get the waiting resume url signed with the signature token */
getSignedResumeUrl(parameters?: Record<string, string>): string;
/** Set requirement in the execution for signature token validation */
setSignatureValidationRequired(): void;
getChildNodes(
nodeName: string,
options?: { includeNodeParameters?: boolean },
@@ -2256,6 +2260,11 @@ export interface IRunExecutionData {
waitingExecutionSource: IWaitingForExecutionSource | null;
};
parentExecution?: RelatedExecution;
/**
* This is used to prevent breaking change
* for waiting executions started before signature validation was added
*/
validateSignature?: boolean;
waitTill?: Date;
pushRef?: string;