From 1516ec7c06ab797dbf94fd1b8a0322209e6ee0bc Mon Sep 17 00:00:00 2001 From: Dawid Myslak Date: Wed, 29 Apr 2026 15:45:41 +0200 Subject: [PATCH] feat(Netlify Trigger Node): Add webhook request verification (#29256) --- .../nodes/Netlify/NetlifyTrigger.node.ts | 14 ++ .../nodes/Netlify/NetlifyTriggerHelpers.ts | 51 ++++ .../Netlify/test/NetlifyTrigger.node.test.ts | 227 ++++++++++++++++++ .../test/NetlifyTriggerHelpers.test.ts | 171 +++++++++++++ 4 files changed, 463 insertions(+) create mode 100644 packages/nodes-base/nodes/Netlify/NetlifyTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Netlify/test/NetlifyTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Netlify/test/NetlifyTriggerHelpers.test.ts diff --git a/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts index d8eab8e34d1..f68d4cdd3e8 100644 --- a/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts +++ b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts @@ -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 { @@ -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 { + 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; diff --git a/packages/nodes-base/nodes/Netlify/NetlifyTriggerHelpers.ts b/packages/nodes-base/nodes/Netlify/NetlifyTriggerHelpers.ts new file mode 100644 index 00000000000..eafc34589b5 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/NetlifyTriggerHelpers.ts @@ -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; + } + }, + }); +} diff --git a/packages/nodes-base/nodes/Netlify/test/NetlifyTrigger.node.test.ts b/packages/nodes-base/nodes/Netlify/test/NetlifyTrigger.node.test.ts new file mode 100644 index 00000000000..c02eec092d2 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/test/NetlifyTrigger.node.test.ts @@ -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, + 'getNodeWebhookUrl' | 'getNodeParameter' | 'getWorkflowStaticData' + >; + let mockWebhookFunctions: Pick< + jest.Mocked, + | '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); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Netlify/test/NetlifyTriggerHelpers.test.ts b/packages/nodes-base/nodes/Netlify/test/NetlifyTriggerHelpers.test.ts new file mode 100644 index 00000000000..51690df098d --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/test/NetlifyTriggerHelpers.test.ts @@ -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); + }); + }); +});