feat(MailerLite Trigger Node): Add webhook request verification (#29491)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dawid Myslak 2026-05-07 07:37:51 +02:00 committed by GitHub
parent 1faa3b1f2a
commit 12b7cc6739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 0 deletions

View File

@ -0,0 +1,100 @@
import { createHmac } from 'crypto';
import { verifySignature } from '../../v2/MailerLiteTriggerHelpers';
describe('MailerLiteTriggerHelpers', () => {
let mockWebhookFunctions: any;
const testSecret = 'test-secret-key-12345';
const testPayload = Buffer.from('{"events":[{"type":"subscriber.created"}]}');
beforeEach(() => {
jest.clearAllMocks();
mockWebhookFunctions = {
getRequestObject: jest.fn(),
getWorkflowStaticData: jest.fn(),
};
});
describe('verifySignature', () => {
it('should return true if no secret is configured', () => {
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue(null),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return true when signatures match', () => {
const hmac = createHmac('sha256', testSecret);
hmac.update(testPayload);
const expectedSignature = hmac.digest('hex');
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'signature') return expectedSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return false when signatures do not match', () => {
const wrongSignature = 'a'.repeat(64);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'signature') return wrongSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
it('should return false when signature header is missing', () => {
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue(null),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
it('should return false when secret is configured but rawBody is missing', () => {
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue('any-signature'),
rawBody: undefined,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
});
});

View File

@ -0,0 +1,78 @@
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
import { MailerLiteTriggerV2 } from '../../v2/MailerLiteTriggerV2.node';
jest.mock('../../v2/MailerLiteTriggerHelpers', () => ({
verifySignature: jest.fn(),
}));
import { verifySignature } from '../../v2/MailerLiteTriggerHelpers';
describe('MailerLiteTriggerV2', () => {
let trigger: MailerLiteTriggerV2;
let mockWebhookFunctions: Partial<IWebhookFunctions>;
let mockResponse: { status: jest.Mock; send: jest.Mock; end: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
trigger = new MailerLiteTriggerV2({
displayName: 'MailerLite Trigger',
name: 'mailerLiteTrigger',
icon: 'file:MailerLite.svg',
group: ['trigger'],
description: 'Starts the workflow when MailerLite events occur',
defaultVersion: 2,
});
mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
};
mockWebhookFunctions = {
getBodyData: jest.fn(),
getResponseObject: jest.fn().mockReturnValue(mockResponse),
helpers: {
returnJsonArray: jest.fn((data) => data),
} as unknown as IWebhookFunctions['helpers'],
};
(verifySignature as jest.Mock).mockReturnValue(true);
});
describe('webhook', () => {
it('should trigger workflow when signature is valid', async () => {
const fields: IDataObject[] = [{ id: '1', name: 'subscriber.created' }];
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({ fields });
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
expect(result.workflowData).toBeDefined();
expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith(fields);
});
it('should return 401 when signature verification fails', async () => {
(verifySignature as jest.Mock).mockReturnValue(false);
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized');
expect(mockResponse.end).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('should trigger workflow when no secret is configured (backward compatibility)', async () => {
// verifySignature returns true when no secret is configured
(verifySignature as jest.Mock).mockReturnValue(true);
const fields: IDataObject[] = [{ id: '1' }];
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({ fields });
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
expect(result.workflowData).toBeDefined();
expect(mockResponse.status).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,41 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../../utils/webhook-signature-verification';
/**
* Verifies the MailerLite webhook signature.
*
* MailerLite signs webhooks using HMAC SHA-256:
* 1. Compute HMAC SHA-256 of the raw JSON payload using the webhook secret
* 2. Encode as a hex string
* 3. Compare with the signature in the `Signature` header
*
* The secret is generated by MailerLite when the webhook is created and stored
* in the workflow's static data.
*
* @returns true if the signature is valid, false otherwise
* @returns true if no secret is configured (backward compatibility with old triggers)
*/
export function verifySignature(this: IWebhookFunctions): boolean {
const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node');
const secret = webhookData.webhookSecret;
return verifySignatureGeneric({
getExpectedSignature: () => {
if (!secret || typeof secret !== 'string' || !req.rawBody) {
return null;
}
const hmac = createHmac('sha256', secret);
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
hmac.update(payload);
return hmac.digest('hex');
},
skipIfNoExpectedSignature: !secret || typeof secret !== 'string',
getActualSignature: () => {
const receivedSignature = req.header('signature');
return typeof receivedSignature === 'string' ? receivedSignature : null;
},
});
}

View File

@ -9,6 +9,7 @@ import {
NodeConnectionTypes, NodeConnectionTypes,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { verifySignature } from './MailerLiteTriggerHelpers';
import { mailerliteApiRequest } from '../GenericFunctions'; import { mailerliteApiRequest } from '../GenericFunctions';
export class MailerLiteTriggerV2 implements INodeType { export class MailerLiteTriggerV2 implements INodeType {
@ -120,6 +121,11 @@ export class MailerLiteTriggerV2 implements INodeType {
if (webhook.url === webhookUrl && webhook.events === events) { if (webhook.url === webhookUrl && webhook.events === events) {
// Set webhook-id to be sure that it can be deleted // Set webhook-id to be sure that it can be deleted
webhookData.webhookId = webhook.id as string; webhookData.webhookId = webhook.id as string;
if (typeof webhook.secret === 'string') {
webhookData.webhookSecret = webhook.secret;
} else {
delete webhookData.webhookSecret;
}
return true; return true;
} }
} }
@ -145,6 +151,11 @@ export class MailerLiteTriggerV2 implements INodeType {
} }
webhookData.webhookId = data.id as string; webhookData.webhookId = data.id as string;
if (typeof data.secret === 'string') {
webhookData.webhookSecret = data.secret;
} else {
delete webhookData.webhookSecret;
}
return true; return true;
}, },
async delete(this: IHookFunctions): Promise<boolean> { async delete(this: IHookFunctions): Promise<boolean> {
@ -161,6 +172,7 @@ export class MailerLiteTriggerV2 implements INodeType {
// Remove from the static workflow data so that it is clear // Remove from the static workflow data so that it is clear
// that no webhooks are registered anymore // that no webhooks are registered anymore
delete webhookData.webhookId; delete webhookData.webhookId;
delete webhookData.webhookSecret;
} }
return true; return true;
}, },
@ -168,6 +180,14 @@ export class MailerLiteTriggerV2 implements INodeType {
}; };
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> { async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
if (!verifySignature.call(this)) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return {
noWebhookResponse: true,
};
}
const body = this.getBodyData(); const body = this.getBodyData();
const data = body.fields as IDataObject[]; const data = body.fields as IDataObject[];