feat(Onfleet Trigger Node): Add webhook request verification (#29485)

This commit is contained in:
Dawid Myslak 2026-05-11 23:27:33 +02:00 committed by GitHub
parent da41470311
commit 133a5aa0ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 254 additions and 7 deletions

View File

@ -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.',
},
];
}

View File

@ -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 {

View 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;
}
}

View File

@ -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();
});
});
});

View File

@ -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);
});
});
});