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,
|
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[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user