diff --git a/packages/nodes-base/credentials/BoxOAuth2Api.credentials.ts b/packages/nodes-base/credentials/BoxOAuth2Api.credentials.ts index 70145fbf2b3..5d6c0eb5901 100644 --- a/packages/nodes-base/credentials/BoxOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/BoxOAuth2Api.credentials.ts @@ -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.", + }, ]; } diff --git a/packages/nodes-base/nodes/Box/BoxTrigger.node.ts b/packages/nodes-base/nodes/Box/BoxTrigger.node.ts index ae517e089c0..0534c2b4287 100644 --- a/packages/nodes-base/nodes/Box/BoxTrigger.node.ts +++ b/packages/nodes-base/nodes/Box/BoxTrigger.node.ts @@ -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 { + 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 { diff --git a/packages/nodes-base/nodes/Box/BoxTriggerHelpers.ts b/packages/nodes-base/nodes/Box/BoxTriggerHelpers.ts new file mode 100644 index 00000000000..6a8d0b6d9be --- /dev/null +++ b/packages/nodes-base/nodes/Box/BoxTriggerHelpers.ts @@ -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 { + 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; + } +} diff --git a/packages/nodes-base/nodes/Box/__test__/BoxTrigger.node.test.ts b/packages/nodes-base/nodes/Box/__test__/BoxTrigger.node.test.ts index 6dce32f7d9a..1333988e951 100644 --- a/packages/nodes-base/nodes/Box/__test__/BoxTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Box/__test__/BoxTrigger.node.test.ts @@ -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; + describe('Box Trigger Webhook Lifecycle', () => { const mockHookFunctions = mock(); const mockStaticData: Record = {}; @@ -288,3 +296,63 @@ describe('Box Trigger Webhook Lifecycle', () => { }); }); }); + +describe('Box Trigger webhook()', () => { + let mockWebhookFunctions: ReturnType>; + let mockResponseObject: { status: jest.Mock; send: jest.Mock; end: jest.Mock }; + + beforeEach(() => { + jest.resetAllMocks(); + mockWebhookFunctions = mock(); + + 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(); + }); +}); diff --git a/packages/nodes-base/nodes/Box/__test__/BoxTriggerHelpers.test.ts b/packages/nodes-base/nodes/Box/__test__/BoxTriggerHelpers.test.ts new file mode 100644 index 00000000000..06076a790b1 --- /dev/null +++ b/packages/nodes-base/nodes/Box/__test__/BoxTriggerHelpers.test.ts @@ -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, + 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); + }); +});