mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(MailerLite Trigger Node): Add webhook request verification (#29491)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1faa3b1f2a
commit
12b7cc6739
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from './MailerLiteTriggerHelpers';
|
||||
import { mailerliteApiRequest } from '../GenericFunctions';
|
||||
|
||||
export class MailerLiteTriggerV2 implements INodeType {
|
||||
|
|
@ -120,6 +121,11 @@ export class MailerLiteTriggerV2 implements INodeType {
|
|||
if (webhook.url === webhookUrl && webhook.events === events) {
|
||||
// Set webhook-id to be sure that it can be deleted
|
||||
webhookData.webhookId = webhook.id as string;
|
||||
if (typeof webhook.secret === 'string') {
|
||||
webhookData.webhookSecret = webhook.secret;
|
||||
} else {
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +151,11 @@ export class MailerLiteTriggerV2 implements INodeType {
|
|||
}
|
||||
|
||||
webhookData.webhookId = data.id as string;
|
||||
if (typeof data.secret === 'string') {
|
||||
webhookData.webhookSecret = data.secret;
|
||||
} else {
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
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
|
||||
// that no webhooks are registered anymore
|
||||
delete webhookData.webhookId;
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
|
@ -168,6 +180,14 @@ export class MailerLiteTriggerV2 implements INodeType {
|
|||
};
|
||||
|
||||
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 data = body.fields as IDataObject[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user