mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat(Box Trigger Node): Add webhook request verification (#29483)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
This commit is contained in:
parent
ceb561b87b
commit
a2835d7e88
|
|
@ -48,5 +48,23 @@ export class BoxOAuth2Api implements ICredentialType {
|
|||
type: 'hidden',
|
||||
default: 'body',
|
||||
},
|
||||
{
|
||||
displayName: 'Primary Signature Key',
|
||||
name: 'signingKeyPrimary',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description:
|
||||
"Used to verify the authenticity of webhook requests. Find it in the Box Developer Console under your app's Webhooks tab > Manage signature keys.",
|
||||
},
|
||||
{
|
||||
displayName: 'Secondary Signature Key',
|
||||
name: 'signingKeySecondary',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description:
|
||||
"Used to verify the authenticity of webhook requests during key rotation. Find it in the Box Developer Console under your app's Webhooks tab > Manage signature keys.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
NodeConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from './BoxTriggerHelpers';
|
||||
import { boxApiRequest, boxApiRequestAllItemsMarker } from './GenericFunctions';
|
||||
|
||||
export class BoxTrigger implements INodeType {
|
||||
|
|
@ -351,6 +352,15 @@ export class BoxTrigger implements INodeType {
|
|||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const isSignatureValid = await verifySignature.call(this);
|
||||
if (!isSignatureValid) {
|
||||
const res = this.getResponseObject();
|
||||
res.status(401).send('Unauthorized').end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyData = this.getBodyData();
|
||||
|
||||
return {
|
||||
|
|
|
|||
66
packages/nodes-base/nodes/Box/BoxTriggerHelpers.ts
Normal file
66
packages/nodes-base/nodes/Box/BoxTriggerHelpers.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
|
||||
|
||||
const MAX_TIMESTAMP_AGE_SECONDS = 600;
|
||||
|
||||
function computeSignature(signingKey: string, rawBody: Buffer | string, timestamp: string): string {
|
||||
const hmac = createHmac('sha256', signingKey);
|
||||
const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
|
||||
hmac.update(payload);
|
||||
hmac.update(timestamp);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
|
||||
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
|
||||
try {
|
||||
const credential = await this.getCredentials('boxOAuth2Api');
|
||||
const primaryKey = credential.signingKeyPrimary;
|
||||
const secondaryKey = credential.signingKeySecondary;
|
||||
|
||||
const primaryConfigured = !!primaryKey && typeof primaryKey === 'string';
|
||||
const secondaryConfigured = !!secondaryKey && typeof secondaryKey === 'string';
|
||||
|
||||
if (!primaryConfigured && !secondaryConfigured) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = this.getRequestObject();
|
||||
const timestampHeader = req.header('box-delivery-timestamp');
|
||||
if (typeof timestampHeader !== 'string' || timestampHeader.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestampMs = Date.parse(timestampHeader);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return false;
|
||||
}
|
||||
const timestampSec = Math.floor(timestampMs / 1000);
|
||||
|
||||
if (!req.rawBody) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tryVerify = (signingKey: string, signatureHeader: string): boolean =>
|
||||
verifySignatureGeneric({
|
||||
getExpectedSignature: () => computeSignature(signingKey, req.rawBody, timestampHeader),
|
||||
getActualSignature: () => {
|
||||
const sig = req.header(signatureHeader);
|
||||
return typeof sig === 'string' ? sig : null;
|
||||
},
|
||||
getTimestamp: () => timestampSec,
|
||||
maxTimestampAgeSeconds: MAX_TIMESTAMP_AGE_SECONDS,
|
||||
});
|
||||
|
||||
if (primaryConfigured && tryVerify(primaryKey, 'box-signature-primary')) {
|
||||
return true;
|
||||
}
|
||||
if (secondaryConfigured && tryVerify(secondaryKey, 'box-signature-secondary')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { IHookFunctions } from 'n8n-workflow';
|
||||
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { BoxTrigger } from '../BoxTrigger.node';
|
||||
|
||||
jest.mock('../BoxTriggerHelpers', () => ({
|
||||
verifySignature: jest.fn(),
|
||||
}));
|
||||
|
||||
import { verifySignature } from '../BoxTriggerHelpers';
|
||||
|
||||
const mockedVerifySignature = verifySignature as jest.MockedFunction<typeof verifySignature>;
|
||||
|
||||
describe('Box Trigger Webhook Lifecycle', () => {
|
||||
const mockHookFunctions = mock<IHookFunctions>();
|
||||
const mockStaticData: Record<string, string> = {};
|
||||
|
|
@ -288,3 +296,63 @@ describe('Box Trigger Webhook Lifecycle', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Box Trigger webhook()', () => {
|
||||
let mockWebhookFunctions: ReturnType<typeof mock<IWebhookFunctions>>;
|
||||
let mockResponseObject: { status: jest.Mock; send: jest.Mock; end: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
|
||||
mockResponseObject = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponseObject as never);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue({
|
||||
type: 'webhook_event',
|
||||
trigger: 'FILE.UPLOADED',
|
||||
});
|
||||
mockWebhookFunctions.helpers = {
|
||||
...mockWebhookFunctions.helpers,
|
||||
returnJsonArray: jest.fn().mockImplementation((data) => [{ json: data }]),
|
||||
};
|
||||
});
|
||||
|
||||
it('should return workflow data when signature is valid', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
|
||||
const boxTrigger = new BoxTrigger();
|
||||
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(result.noWebhookResponse).toBeUndefined();
|
||||
expect(mockResponseObject.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond with 401 and noWebhookResponse when signature is invalid', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(false);
|
||||
|
||||
const boxTrigger = new BoxTrigger();
|
||||
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toBeUndefined();
|
||||
expect(result.noWebhookResponse).toBe(true);
|
||||
expect(mockResponseObject.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponseObject.send).toHaveBeenCalledWith('Unauthorized');
|
||||
expect(mockResponseObject.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return workflow data when no signing keys are configured (backward compatibility)', async () => {
|
||||
// verifySignature returns true when no keys are configured
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
|
||||
const boxTrigger = new BoxTrigger();
|
||||
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(mockResponseObject.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
231
packages/nodes-base/nodes/Box/__test__/BoxTriggerHelpers.test.ts
Normal file
231
packages/nodes-base/nodes/Box/__test__/BoxTriggerHelpers.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { createHmac } from 'crypto';
|
||||
|
||||
import { verifySignature } from '../BoxTriggerHelpers';
|
||||
|
||||
describe('BoxTriggerHelpers', () => {
|
||||
const primaryKey = 'primary-test-signing-key';
|
||||
const secondaryKey = 'secondary-test-signing-key';
|
||||
const rawBody = Buffer.from('{"type":"webhook_event","trigger":"FILE.UPLOADED"}');
|
||||
// Fixed clock at 2024-01-01T00:05:00Z (300 seconds after the timestamp header)
|
||||
const fixedNow = Date.parse('2024-01-01T00:05:00Z');
|
||||
const deliveryTimestamp = '2024-01-01T00:00:00Z';
|
||||
|
||||
const computeSignature = (key: string): string => {
|
||||
const hmac = createHmac('sha256', key);
|
||||
hmac.update(rawBody);
|
||||
hmac.update(deliveryTimestamp);
|
||||
return hmac.digest('base64');
|
||||
};
|
||||
|
||||
const validPrimarySignature = computeSignature(primaryKey);
|
||||
const validSecondarySignature = computeSignature(secondaryKey);
|
||||
|
||||
let mockWebhookFunctions: any;
|
||||
|
||||
const buildRequest = (
|
||||
headers: Record<string, string | undefined>,
|
||||
body: Buffer | string | undefined | null = rawBody,
|
||||
) => ({
|
||||
header: jest.fn((name: string) => headers[name.toLowerCase()] ?? null),
|
||||
rawBody: body,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => fixedNow);
|
||||
|
||||
mockWebhookFunctions = {
|
||||
getCredentials: jest.fn(),
|
||||
getRequestObject: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Box Trigger' }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return true when no signing keys are configured (backward compatibility)', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(buildRequest({}));
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when signing keys are empty strings (backward compatibility)', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: '',
|
||||
signingKeySecondary: '',
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(buildRequest({}));
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when primary signature matches', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
signingKeySecondary: secondaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
'box-signature-secondary': 'invalid',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when only secondary signature matches', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
signingKeySecondary: secondaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': 'invalid',
|
||||
'box-signature-secondary': validSecondarySignature,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when only primary key is configured and primary signature matches', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when both signatures are invalid', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
signingKeySecondary: secondaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': 'invalid-primary',
|
||||
'box-signature-secondary': 'invalid-secondary',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signature headers are missing', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
signingKeySecondary: secondaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when delivery timestamp header is missing', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when delivery timestamp is not parseable', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': 'not-a-valid-date',
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when delivery timestamp is older than 10 minutes', async () => {
|
||||
// 11 minutes after the delivery timestamp
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => Date.parse('2024-01-01T00:11:00Z'));
|
||||
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest({
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when raw body is missing', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
signingKeyPrimary: primaryKey,
|
||||
});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(
|
||||
buildRequest(
|
||||
{
|
||||
'box-delivery-timestamp': deliveryTimestamp,
|
||||
'box-signature-primary': validPrimarySignature,
|
||||
},
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when getCredentials throws', async () => {
|
||||
mockWebhookFunctions.getCredentials.mockRejectedValue(new Error('credential not found'));
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user