diff --git a/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts b/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts index 6a30bcfba56..5604ac84344 100644 --- a/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts +++ b/packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts @@ -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[])], }; diff --git a/packages/nodes-base/nodes/Asana/AsanaTriggerHelpers.ts b/packages/nodes-base/nodes/Asana/AsanaTriggerHelpers.ts new file mode 100644 index 00000000000..c7c9f489a5b --- /dev/null +++ b/packages/nodes-base/nodes/Asana/AsanaTriggerHelpers.ts @@ -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; + }, + }); +} diff --git a/packages/nodes-base/nodes/Asana/test/AsanaTrigger.node.test.ts b/packages/nodes-base/nodes/Asana/test/AsanaTrigger.node.test.ts new file mode 100644 index 00000000000..8c11acd0850 --- /dev/null +++ b/packages/nodes-base/nodes/Asana/test/AsanaTrigger.node.test.ts @@ -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, + | '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({}); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Asana/test/AsanaTriggerHelpers.test.ts b/packages/nodes-base/nodes/Asana/test/AsanaTriggerHelpers.test.ts new file mode 100644 index 00000000000..a1fc7313465 --- /dev/null +++ b/packages/nodes-base/nodes/Asana/test/AsanaTriggerHelpers.test.ts @@ -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); + }); + }); +});