feat(Netlify Trigger Node): Add webhook request verification (#29256)

This commit is contained in:
Dawid Myslak
2026-04-29 15:45:41 +02:00
committed by GitHub
parent 898ba5ae25
commit 1516ec7c06
4 changed files with 463 additions and 0 deletions
@@ -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);
});
});
});