feat(Asana Trigger Node): Add webhook request verification (#29258)

This commit is contained in:
Dawid Myslak 2026-05-11 22:04:15 +02:00 committed by GitHub
parent 267fe49d51
commit 94e403300b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 318 additions and 16 deletions

View File

@ -10,12 +10,9 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { verifySignature } from './AsanaTriggerHelpers';
import { asanaApiRequest, getWorkspaces } from './GenericFunctions';
// import {
// createHmac,
// } from 'crypto';
export class AsanaTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Asana Trigger',
@ -209,6 +206,12 @@ export class AsanaTrigger implements INodeType {
};
}
if (!verifySignature.call(this)) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return { noWebhookResponse: true };
}
// Is regular webhook call
// Check if it contains any events
if (
@ -221,18 +224,6 @@ export class AsanaTrigger implements INodeType {
return {};
}
// TODO: Had to be deactivated as it is currently not possible to get the secret
// in production mode as the static data overwrites each other because the
// two exist at the same time (create webhook [with webhookId] and receive
// webhook [with secret])
// // Check if the request is valid
// // (if the signature matches to data and hookSecret)
// const computedSignature = createHmac('sha256', webhookData.hookSecret as string).update(JSON.stringify(req.body)).digest('hex');
// if (headerData['x-hook-signature'] !== computedSignature) {
// // Signature is not valid so ignore call
// return {};
// }
return {
workflowData: [this.helpers.returnJsonArray(req.body.events as IDataObject[])],
};

View File

@ -0,0 +1,40 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
/**
* Verifies the Asana webhook signature.
*
* Asana signs webhooks using HMAC SHA-256:
* 1. Compute an HMAC SHA-256 of the raw request body using the shared secret
* received during the initial handshake (X-Hook-Secret header)
* 2. Encode the digest as a hexadecimal string
* 3. Compare against the value of the `X-Hook-Signature` header
*
* @returns true if the signature is valid, or no secret has been stored yet
* (backward compatibility with webhooks created before verification
* was introduced); false otherwise.
*/
export function verifySignature(this: IWebhookFunctions): boolean {
const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node');
const secret = webhookData.hookSecret;
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('x-hook-signature');
return typeof receivedSignature === 'string' ? receivedSignature : null;
},
});
}

View File

@ -0,0 +1,149 @@
import type { IWebhookFunctions } from 'n8n-workflow';
import { AsanaTrigger } from '../AsanaTrigger.node';
import { verifySignature } from '../AsanaTriggerHelpers';
jest.mock('../AsanaTriggerHelpers');
jest.mock('../GenericFunctions');
describe('AsanaTrigger', () => {
let trigger: AsanaTrigger;
let mockWebhookFunctions: Pick<
jest.Mocked<IWebhookFunctions>,
| 'getBodyData'
| 'getHeaderData'
| 'getRequestObject'
| 'getResponseObject'
| 'getWorkflowStaticData'
| 'helpers'
>;
beforeEach(() => {
jest.clearAllMocks();
trigger = new AsanaTrigger();
mockWebhookFunctions = {
getBodyData: jest.fn(),
getHeaderData: jest.fn(),
getRequestObject: jest.fn(),
getResponseObject: jest.fn(),
getWorkflowStaticData: jest.fn(),
helpers: {
returnJsonArray: jest.fn((data) => data),
} as any,
};
});
describe('webhook', () => {
it('should complete the handshake when X-Hook-Secret header is present', async () => {
const handshakeSecret = 'asana-handshake-secret';
const mockResponse = {
set: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
end: jest.fn(),
};
const webhookData: any = {};
mockWebhookFunctions.getBodyData.mockReturnValue({});
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hook-secret': handshakeSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({} as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse as any);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(webhookData.hookSecret).toBe(handshakeSecret);
expect(mockResponse.set).toHaveBeenCalledWith('X-Hook-Secret', handshakeSecret);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(verifySignature).not.toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('should return 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.getBodyData.mockReturnValue({});
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({} as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse as any);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
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 process events when signature verification passes', async () => {
const events = [{ action: 'changed', resource: { gid: '1' } }];
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue({ events });
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({
body: { events },
} as any);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: 'secret',
});
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(verifySignature).toHaveBeenCalled();
expect(result.workflowData).toBeDefined();
expect(result.workflowData?.[0]).toEqual(events);
});
it('should process events when no secret is configured (backward compatibility)', async () => {
const events = [{ action: 'added' }];
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue({ events });
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({
body: { events },
} as any);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(verifySignature).toHaveBeenCalled();
expect(result.workflowData).toBeDefined();
expect(result.workflowData?.[0]).toEqual(events);
});
it('should return empty result when events array is empty after verification', async () => {
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue({ events: [] });
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({
body: { events: [] },
} as any);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(verifySignature).toHaveBeenCalled();
expect(result).toEqual({});
});
});
});

View File

@ -0,0 +1,122 @@
import { createHmac } from 'crypto';
import { verifySignature } from '../AsanaTriggerHelpers';
describe('AsanaTriggerHelpers', () => {
let mockWebhookFunctions: any;
const testSecret = 'test-secret-key-12345';
const testPayload = Buffer.from('{"events":[{"action":"changed"}]}');
beforeEach(() => {
jest.clearAllMocks();
mockWebhookFunctions = {
getRequestObject: jest.fn(),
getWorkflowStaticData: jest.fn(),
};
});
describe('verifySignature', () => {
it('should return true if no secret is configured (backward compatibility)', () => {
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 if signatures match', () => {
const hmac = createHmac('sha256', testSecret);
hmac.update(testPayload);
const expectedSignature = hmac.digest('hex');
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'x-hook-signature') return expectedSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return false if signatures do not match', () => {
const wrongSignature = '0'.repeat(64);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'x-hook-signature') return wrongSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
it('should return false if signature header is missing', () => {
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue(null),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
it('should return true if rawBody is a string and signature matches', () => {
const stringPayload = '{"events":[{"action":"changed"}]}';
const hmac = createHmac('sha256', testSecret);
hmac.update(Buffer.from(stringPayload));
const expectedSignature = hmac.digest('hex');
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header: string) => {
if (header === 'x-hook-signature') return expectedSignature;
return null;
}),
rawBody: stringPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return false if raw body is missing when secret is set', () => {
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
hookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue('any'),
rawBody: undefined,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
});
});