mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(Asana Trigger Node): Add webhook request verification (#29258)
This commit is contained in:
parent
267fe49d51
commit
94e403300b
|
|
@ -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[])],
|
||||
};
|
||||
|
|
|
|||
40
packages/nodes-base/nodes/Asana/AsanaTriggerHelpers.ts
Normal file
40
packages/nodes-base/nodes/Asana/AsanaTriggerHelpers.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
149
packages/nodes-base/nodes/Asana/test/AsanaTrigger.node.test.ts
Normal file
149
packages/nodes-base/nodes/Asana/test/AsanaTrigger.node.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/nodes-base/nodes/Asana/test/AsanaTriggerHelpers.test.ts
Normal file
122
packages/nodes-base/nodes/Asana/test/AsanaTriggerHelpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user