mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Netlify Trigger Node): Add webhook request verification (#29256)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { snakeCase } from 'change-case';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type {
|
||||
IHookFunctions,
|
||||
IWebhookFunctions,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { netlifyApiRequest } from './GenericFunctions';
|
||||
import { verifySignature } from './NetlifyTriggerHelpers';
|
||||
|
||||
export class NetlifyTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -137,10 +139,12 @@ export class NetlifyTrigger implements INodeType {
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
const body: IDataObject = {
|
||||
event: snakeCase(event),
|
||||
data: {
|
||||
url: webhookUrl,
|
||||
signature_secret: webhookSecret,
|
||||
},
|
||||
site_id: this.getNodeParameter('siteId') as string,
|
||||
};
|
||||
@@ -150,6 +154,7 @@ export class NetlifyTrigger implements INodeType {
|
||||
}
|
||||
const webhook = await netlifyApiRequest.call(this, 'POST', '/hooks', body);
|
||||
webhookData.webhookId = webhook.id;
|
||||
webhookData.webhookSecret = webhookSecret;
|
||||
return true;
|
||||
},
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
@@ -160,6 +165,7 @@ export class NetlifyTrigger implements INodeType {
|
||||
return false;
|
||||
}
|
||||
delete webhookData.webhookId;
|
||||
delete webhookData.webhookSecret;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -196,6 +202,14 @@ export class NetlifyTrigger 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 req = this.getRequestObject();
|
||||
const simple = this.getNodeParameter('simple', false) as boolean;
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
|
||||
|
||||
/**
|
||||
* Verifies the Netlify webhook signature.
|
||||
*
|
||||
* Netlify signs webhooks using a JSON Web Signature (HS256):
|
||||
* 1. The `X-Webhook-Signature` header carries a JWT signed with the shared
|
||||
* secret.
|
||||
* 2. The JWT payload includes a `sha256` claim — the hex SHA-256 digest of
|
||||
* the raw request body.
|
||||
* 3. Verifying the JWT confirms authenticity; comparing the `sha256` claim
|
||||
* with the computed digest of the body confirms payload integrity.
|
||||
*
|
||||
* @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 payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
|
||||
return createHash('sha256').update(payload).digest('hex');
|
||||
},
|
||||
skipIfNoExpectedSignature: !secret || typeof secret !== 'string',
|
||||
getActualSignature: () => {
|
||||
const token = req.header('x-webhook-signature');
|
||||
if (typeof token !== 'string' || !token || typeof secret !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'netlify',
|
||||
}) as IDataObject;
|
||||
return typeof decoded.sha256 === 'string' ? decoded.sha256 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { netlifyApiRequest } from '../GenericFunctions';
|
||||
import { NetlifyTrigger } from '../NetlifyTrigger.node';
|
||||
import { verifySignature } from '../NetlifyTriggerHelpers';
|
||||
|
||||
jest.mock('../GenericFunctions');
|
||||
jest.mock('../NetlifyTriggerHelpers');
|
||||
jest.mock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomBytes: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('NetlifyTrigger', () => {
|
||||
let trigger: NetlifyTrigger;
|
||||
let mockHookFunctions: Pick<
|
||||
jest.Mocked<IHookFunctions>,
|
||||
'getNodeWebhookUrl' | 'getNodeParameter' | 'getWorkflowStaticData'
|
||||
>;
|
||||
let mockWebhookFunctions: Pick<
|
||||
jest.Mocked<IWebhookFunctions>,
|
||||
| 'getNodeParameter'
|
||||
| 'getRequestObject'
|
||||
| 'getResponseObject'
|
||||
| 'getWorkflowStaticData'
|
||||
| 'helpers'
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
trigger = new NetlifyTrigger();
|
||||
|
||||
mockHookFunctions = {
|
||||
getNodeWebhookUrl: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
getWorkflowStaticData: jest.fn(),
|
||||
};
|
||||
|
||||
mockWebhookFunctions = {
|
||||
getNodeParameter: jest.fn(),
|
||||
getRequestObject: jest.fn(),
|
||||
getResponseObject: jest.fn(),
|
||||
getWorkflowStaticData: jest.fn(),
|
||||
helpers: {
|
||||
returnJsonArray: jest.fn((data) => data),
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.create', () => {
|
||||
it('should generate a signing secret and pass it to Netlify', async () => {
|
||||
const webhookUrl = 'https://example.com/webhook';
|
||||
const siteId = 'site-123';
|
||||
const webhookSecret = 'a'.repeat(64);
|
||||
const webhookId = 'hook-1';
|
||||
|
||||
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
|
||||
mockHookFunctions.getNodeParameter.mockImplementation((name) => {
|
||||
if (name === 'event') return 'deployCreated';
|
||||
if (name === 'siteId') return siteId;
|
||||
if (name === 'formId') return '*';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const webhookData: any = {};
|
||||
mockHookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
|
||||
|
||||
(randomBytes as jest.Mock).mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(webhookSecret),
|
||||
});
|
||||
(netlifyApiRequest as jest.Mock).mockResolvedValue({ id: webhookId });
|
||||
|
||||
const result = await trigger.webhookMethods!.default.create.call(
|
||||
mockHookFunctions as unknown as IHookFunctions,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(randomBytes).toHaveBeenCalledWith(32);
|
||||
expect(netlifyApiRequest).toHaveBeenCalledWith('POST', '/hooks', {
|
||||
event: 'deploy_created',
|
||||
data: {
|
||||
url: webhookUrl,
|
||||
signature_secret: webhookSecret,
|
||||
},
|
||||
site_id: siteId,
|
||||
});
|
||||
expect(webhookData.webhookId).toBe(webhookId);
|
||||
expect(webhookData.webhookSecret).toBe(webhookSecret);
|
||||
});
|
||||
|
||||
it('should include form_id when event is submissionCreated and a form is selected', async () => {
|
||||
const webhookUrl = 'https://example.com/webhook';
|
||||
const siteId = 'site-123';
|
||||
const formId = 'form-1';
|
||||
const webhookSecret = 'b'.repeat(64);
|
||||
|
||||
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
|
||||
mockHookFunctions.getNodeParameter.mockImplementation((name) => {
|
||||
if (name === 'event') return 'submissionCreated';
|
||||
if (name === 'siteId') return siteId;
|
||||
if (name === 'formId') return formId;
|
||||
return undefined;
|
||||
});
|
||||
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
|
||||
|
||||
(randomBytes as jest.Mock).mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(webhookSecret),
|
||||
});
|
||||
(netlifyApiRequest as jest.Mock).mockResolvedValue({ id: 'hook-2' });
|
||||
|
||||
await trigger.webhookMethods!.default.create.call(
|
||||
mockHookFunctions as unknown as IHookFunctions,
|
||||
);
|
||||
|
||||
expect(netlifyApiRequest).toHaveBeenCalledWith('POST', '/hooks', {
|
||||
event: 'submission_created',
|
||||
data: {
|
||||
url: webhookUrl,
|
||||
signature_secret: webhookSecret,
|
||||
},
|
||||
site_id: siteId,
|
||||
form_id: formId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.delete', () => {
|
||||
it('should remove the webhook and clear the stored secret', async () => {
|
||||
const webhookId = 'hook-1';
|
||||
const webhookData: any = {
|
||||
webhookId,
|
||||
webhookSecret: 'stored-secret',
|
||||
};
|
||||
|
||||
mockHookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
|
||||
(netlifyApiRequest as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await trigger.webhookMethods!.default.delete.call(
|
||||
mockHookFunctions as unknown as IHookFunctions,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(netlifyApiRequest).toHaveBeenCalledWith('DELETE', `/hooks/${webhookId}`);
|
||||
expect(webhookData.webhookId).toBeUndefined();
|
||||
expect(webhookData.webhookSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook', () => {
|
||||
it('should respond 401 when signature verification fails', async () => {
|
||||
const mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
|
||||
(verifySignature as jest.Mock).mockReturnValue(false);
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse as any);
|
||||
|
||||
const result = await trigger.webhook.call(
|
||||
mockWebhookFunctions as unknown as IWebhookFunctions,
|
||||
);
|
||||
|
||||
expect(verifySignature).toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized');
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return workflow data when verification passes', async () => {
|
||||
const body = { id: 'deploy-1', state: 'ready' };
|
||||
|
||||
(verifySignature as jest.Mock).mockReturnValue(true);
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((name) => {
|
||||
if (name === 'simple') return false;
|
||||
if (name === 'event') return 'deployCreated';
|
||||
return undefined;
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({ body } as any);
|
||||
|
||||
const result = await trigger.webhook.call(
|
||||
mockWebhookFunctions as unknown as IWebhookFunctions,
|
||||
);
|
||||
|
||||
expect(verifySignature).toHaveBeenCalled();
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return workflow data when no secret is configured (backward compat)', async () => {
|
||||
const body = { id: 'deploy-1', state: 'ready' };
|
||||
|
||||
(verifySignature as jest.Mock).mockReturnValue(true);
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((name) => {
|
||||
if (name === 'simple') return false;
|
||||
if (name === 'event') return 'deployCreated';
|
||||
return undefined;
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({ body } as any);
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
|
||||
|
||||
const result = await trigger.webhook.call(
|
||||
mockWebhookFunctions as unknown as IWebhookFunctions,
|
||||
);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('should unwrap data field for simplified submissionCreated payloads', async () => {
|
||||
const body = { data: { name: 'Alice', email: 'alice@example.com' } };
|
||||
|
||||
(verifySignature as jest.Mock).mockReturnValue(true);
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((name) => {
|
||||
if (name === 'simple') return true;
|
||||
if (name === 'event') return 'submissionCreated';
|
||||
return undefined;
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({ body } as any);
|
||||
|
||||
await trigger.webhook.call(mockWebhookFunctions as unknown as IWebhookFunctions);
|
||||
|
||||
expect(mockWebhookFunctions.helpers.returnJsonArray).toHaveBeenCalledWith(body.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createHash } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { verifySignature } from '../NetlifyTriggerHelpers';
|
||||
|
||||
describe('NetlifyTriggerHelpers', () => {
|
||||
let mockWebhookFunctions: any;
|
||||
const testSecret = 'test-secret-key-12345';
|
||||
const testPayload = Buffer.from('{"event":"deploy_created"}');
|
||||
|
||||
const signPayload = (secret: string, payload: Buffer) => {
|
||||
const sha256 = createHash('sha256').update(payload).digest('hex');
|
||||
return jwt.sign({ sha256 }, secret, {
|
||||
algorithm: 'HS256',
|
||||
issuer: 'netlify',
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockWebhookFunctions = {
|
||||
getRequestObject: jest.fn(),
|
||||
getWorkflowStaticData: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true when 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 signature is valid', () => {
|
||||
const token = signPayload(testSecret, testPayload);
|
||||
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return token;
|
||||
return null;
|
||||
}),
|
||||
rawBody: testPayload,
|
||||
});
|
||||
|
||||
const result = verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when JWT was signed with a different secret', () => {
|
||||
const token = signPayload('wrong-secret', testPayload);
|
||||
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return token;
|
||||
return null;
|
||||
}),
|
||||
rawBody: testPayload,
|
||||
});
|
||||
|
||||
const result = verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when body digest does not match the signed claim', () => {
|
||||
const token = signPayload(testSecret, Buffer.from('{"event":"other"}'));
|
||||
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return token;
|
||||
return null;
|
||||
}),
|
||||
rawBody: testPayload,
|
||||
});
|
||||
|
||||
const result = verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when JWT was issued by a different party', () => {
|
||||
const sha256 = createHash('sha256').update(testPayload).digest('hex');
|
||||
const token = jwt.sign({ sha256 }, testSecret, {
|
||||
algorithm: 'HS256',
|
||||
issuer: 'someone-else',
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return token;
|
||||
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 signature header is malformed', () => {
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return 'not-a-jwt';
|
||||
return null;
|
||||
}),
|
||||
rawBody: testPayload,
|
||||
});
|
||||
|
||||
const result = verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when raw body is missing', () => {
|
||||
const token = signPayload(testSecret, testPayload);
|
||||
|
||||
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
|
||||
webhookSecret: testSecret,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'x-webhook-signature') return token;
|
||||
return null;
|
||||
}),
|
||||
rawBody: undefined,
|
||||
});
|
||||
|
||||
const result = verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user