mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(Onfleet Trigger Node): Add webhook request verification (#29485)
This commit is contained in:
parent
da41470311
commit
133a5aa0ad
|
|
@ -15,5 +15,14 @@ export class OnfleetApi implements ICredentialType {
|
|||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Signing Secret',
|
||||
name: 'signingSecret',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description:
|
||||
'Used to verify webhook authenticity. Found in Onfleet under Settings → API & Webhooks.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workf
|
|||
|
||||
import { eventDisplay, eventNameField } from './descriptions/OnfleetWebhookDescription';
|
||||
import { onfleetApiRequest } from './GenericFunctions';
|
||||
import { verifySignature } from './OnfleetTriggerHelpers';
|
||||
import { webhookMapping } from './WebhookMapping';
|
||||
|
||||
export class OnfleetTrigger implements INodeType {
|
||||
|
|
@ -142,6 +143,13 @@ export class OnfleetTrigger implements INodeType {
|
|||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
const isSignatureValid = await verifySignature.call(this);
|
||||
if (!isSignatureValid) {
|
||||
const res = this.getResponseObject();
|
||||
res.status(401).send('Unauthorized').end();
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
const returnData: IDataObject = this.getBodyData();
|
||||
|
||||
return {
|
||||
|
|
|
|||
33
packages/nodes-base/nodes/Onfleet/OnfleetTriggerHelpers.ts
Normal file
33
packages/nodes-base/nodes/Onfleet/OnfleetTriggerHelpers.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
|
||||
|
||||
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
|
||||
try {
|
||||
const credential = await this.getCredentials('onfleetApi');
|
||||
const req = this.getRequestObject();
|
||||
const signingSecret = credential.signingSecret;
|
||||
|
||||
return verifySignatureGeneric({
|
||||
getExpectedSignature: () => {
|
||||
if (!signingSecret || typeof signingSecret !== 'string' || !req.rawBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secretBuffer = Buffer.from(signingSecret, 'hex');
|
||||
const hmac = createHmac('sha512', secretBuffer);
|
||||
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
|
||||
hmac.update(payload);
|
||||
return hmac.digest('hex');
|
||||
},
|
||||
skipIfNoExpectedSignature: !signingSecret || typeof signingSecret !== 'string',
|
||||
getActualSignature: () => {
|
||||
const sig = req.header('x-onfleet-signature');
|
||||
return typeof sig === 'string' ? sig : null;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { OnfleetTrigger } from '../OnfleetTrigger.node';
|
||||
|
|
@ -6,6 +7,15 @@ describe('Onfleet Trigger Node', () => {
|
|||
let node: OnfleetTrigger;
|
||||
let mockWebhookFunctions: IWebhookFunctions;
|
||||
|
||||
const testSecretHex = 'a'.repeat(64);
|
||||
const testBody = '{"taskId":"task123","actionContext":"COMPLETE"}';
|
||||
|
||||
const computeSignature = (secretHex: string, body: string): string => {
|
||||
const hmac = createHmac('sha512', Buffer.from(secretHex, 'hex'));
|
||||
hmac.update(Buffer.from(body));
|
||||
return hmac.digest('hex');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
node = new OnfleetTrigger();
|
||||
mockWebhookFunctions = {
|
||||
|
|
@ -13,6 +23,7 @@ describe('Onfleet Trigger Node', () => {
|
|||
getRequestObject: jest.fn(),
|
||||
getResponseObject: jest.fn(),
|
||||
getBodyData: jest.fn(),
|
||||
getCredentials: jest.fn(),
|
||||
helpers: {
|
||||
returnJsonArray: jest.fn().mockImplementation((data) => [{ json: data }]),
|
||||
},
|
||||
|
|
@ -48,19 +59,24 @@ describe('Onfleet Trigger Node', () => {
|
|||
});
|
||||
|
||||
describe('default webhook', () => {
|
||||
it('should process incoming webhook data', async () => {
|
||||
const mockRequestData = {
|
||||
taskId: 'task123',
|
||||
workerId: 'worker456',
|
||||
actionContext: 'COMPLETE',
|
||||
};
|
||||
const mockRequestData = {
|
||||
taskId: 'task123',
|
||||
workerId: 'worker456',
|
||||
actionContext: 'COMPLETE',
|
||||
};
|
||||
|
||||
it('should process the request when no signing secret is configured (backward compatibility)', async () => {
|
||||
const mockRequest = {
|
||||
query: {},
|
||||
header: jest.fn().mockReturnValue(undefined),
|
||||
rawBody: Buffer.from(testBody),
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getWebhookName as jest.Mock).mockReturnValue('default');
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue(mockRequest);
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(mockRequestData);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
|
@ -68,7 +84,65 @@ describe('Onfleet Trigger Node', () => {
|
|||
expect(result).toEqual({
|
||||
workflowData: [[{ json: mockRequestData }]],
|
||||
});
|
||||
expect(mockWebhookFunctions.helpers.returnJsonArray).toHaveBeenCalledWith(mockRequestData);
|
||||
});
|
||||
|
||||
it('should process the request when the signature is valid', async () => {
|
||||
const validSignature = computeSignature(testSecretHex, testBody);
|
||||
const mockRequest = {
|
||||
query: {},
|
||||
header: jest.fn().mockImplementation((header: string) => {
|
||||
if (header === 'x-onfleet-signature') return validSignature;
|
||||
return undefined;
|
||||
}),
|
||||
rawBody: Buffer.from(testBody),
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getWebhookName as jest.Mock).mockReturnValue('default');
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue(mockRequest);
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(mockRequestData);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
workflowData: [[{ json: mockRequestData }]],
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with 401 and not trigger the workflow when the signature is invalid', async () => {
|
||||
const mockRequest = {
|
||||
query: {},
|
||||
header: jest.fn().mockImplementation((header: string) => {
|
||||
if (header === 'x-onfleet-signature') return 'f'.repeat(128);
|
||||
return undefined;
|
||||
}),
|
||||
rawBody: Buffer.from(testBody),
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getWebhookName as jest.Mock).mockReturnValue('default');
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue(mockRequest);
|
||||
(mockWebhookFunctions.getResponseObject as jest.Mock).mockReturnValue(mockResponse);
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized');
|
||||
expect(mockResponse.end).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
expect(mockWebhookFunctions.getBodyData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { createHmac } from 'crypto';
|
||||
|
||||
import { verifySignature } from '../OnfleetTriggerHelpers';
|
||||
|
||||
describe('OnfleetTriggerHelpers', () => {
|
||||
const testSecretHex = 'a'.repeat(64);
|
||||
const testBody = '{"taskId":"task123","actionContext":"COMPLETE"}';
|
||||
|
||||
const computeSignature = (secretHex: string, body: string): string => {
|
||||
const hmac = createHmac('sha512', Buffer.from(secretHex, 'hex'));
|
||||
hmac.update(Buffer.from(body));
|
||||
return hmac.digest('hex');
|
||||
};
|
||||
|
||||
const validSignature = computeSignature(testSecretHex, testBody);
|
||||
|
||||
let mockWebhookFunctions: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockWebhookFunctions = {
|
||||
getCredentials: jest.fn(),
|
||||
getRequestObject: jest.fn(),
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((header: string) => {
|
||||
if (header === 'x-onfleet-signature') return validSignature;
|
||||
return undefined;
|
||||
}),
|
||||
rawBody: Buffer.from(testBody),
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('returns true when no credentials are provided (backward compatibility)', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('onfleetApi');
|
||||
});
|
||||
|
||||
it('returns true when no signing secret is configured (backward compatibility)', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when signature is valid', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when signature is invalid', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockImplementation((header: string) => {
|
||||
if (header === 'x-onfleet-signature') return 'f'.repeat(128);
|
||||
return undefined;
|
||||
}),
|
||||
rawBody: Buffer.from(testBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when signature header is missing', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockReturnValue(undefined),
|
||||
rawBody: Buffer.from(testBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when raw body is missing', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingSecret: testSecretHex,
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
header: jest.fn().mockReturnValue(validSignature),
|
||||
rawBody: undefined,
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when getCredentials throws', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockRejectedValue(new Error('credentials not found'));
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user