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:
Dawid Myslak 2026-05-14 09:59:04 +02:00 committed by GitHub
parent ceb561b87b
commit a2835d7e88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 394 additions and 1 deletions

View File

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

View File

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

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

View File

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

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