Skip to content

Commit 09765a4

Browse files
box-sdk-buildbox-sdk-build
and
box-sdk-build
authored
feat: Support webhook message validation (box/box-codegen#631) (#455)
Co-authored-by: box-sdk-build <[email protected]>
1 parent 1cb7ddb commit 09765a4

File tree

7 files changed

+520
-6
lines changed

7 files changed

+520
-6
lines changed

.codegen.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "engineHash": "ce7ab17", "specHash": "6886603", "version": "1.9.0" }
1+
{ "engineHash": "cc6ffb8", "specHash": "6886603", "version": "1.9.0" }

docs/webhooks.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- [Get webhook](#get-webhook)
66
- [Update webhook](#update-webhook)
77
- [Remove webhook](#remove-webhook)
8+
- [Validate a webhook message](#validate-a-webhook-message)
89

910
## List all webhooks
1011

@@ -164,3 +165,35 @@ This function returns a value of type `undefined`.
164165

165166
An empty response will be returned when the webhook
166167
was successfully deleted.
168+
169+
## Validate a webhook message
170+
171+
Validate a webhook message by verifying the signature and the delivery timestamp
172+
173+
This operation is performed by calling function `validateMessage`.
174+
175+
```ts
176+
await WebhooksManager.validateMessage(
177+
bodyWithJapanese,
178+
headersWithJapanese,
179+
primaryKey,
180+
{ secondaryKey: secondaryKey } satisfies ValidateMessageOptionalsInput,
181+
);
182+
```
183+
184+
### Arguments
185+
186+
- body `string`
187+
- The request body of the webhook message
188+
- headers `{
189+
readonly [key: string]: string;
190+
}`
191+
- The headers of the webhook message
192+
- primaryKey `string`
193+
- The primary signature to verify the message with
194+
- optionalsInput `ValidateMessageOptionalsInput`
195+
-
196+
197+
### Returns
198+
199+
This function returns a value of type `boolean`.

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/internal/utils.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Buffer } from 'buffer';
22
import type { Readable } from 'stream';
33
import { v4 as uuidv4 } from 'uuid';
44
import { SignJWT, importPKCS8 } from 'jose';
5-
import { createSHA1 } from 'hash-wasm';
5+
import { createHMAC, createSHA1, createSHA256 } from 'hash-wasm';
66

77
export function isBrowser() {
88
return (
@@ -59,6 +59,14 @@ export function dateTimeToString(dateTime: DateTimeWrapper): string {
5959
);
6060
}
6161

62+
export function epochSecondsToDateTime(seconds: number): DateTimeWrapper {
63+
return new DateTimeWrapper(new Date(seconds * 1000));
64+
}
65+
66+
export function dateTimeToEpochSeconds(dateTime: DateTimeWrapper): number {
67+
return Math.floor(dateTime.value.getTime() / 1000);
68+
}
69+
6270
export {
6371
dateToString as serializeDate,
6472
dateFromString as deserializeDate,
@@ -459,3 +467,63 @@ export function createNull(): null {
459467
export function createCancellationController(): CancellationController {
460468
return new AbortController();
461469
}
470+
471+
/**
472+
* Stringify JSON with escaped multibyte Unicode characters to ensure computed signatures match PHP's default behavior
473+
*
474+
* @param {Object} body - The parsed JSON object
475+
* @returns {string} - Stringified JSON with escaped multibyte Unicode characters
476+
* @private
477+
*/
478+
export function jsonStringifyWithEscapedUnicode(body: string) {
479+
return body.replace(
480+
/[\u007f-\uffff]/g,
481+
(char) => `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}`,
482+
);
483+
}
484+
485+
/**
486+
* Compute the message signature
487+
* @see {@Link https://developer.box.com/en/guides/webhooks/handle/setup-signatures/}
488+
*
489+
* @param {string} body - The request body of the webhook message
490+
* @param {Object} headers - The request headers of the webhook message
491+
* @param {string} signatureKey - The signature to verify the message with
492+
* @returns {?string} - The message signature (or null, if it can't be computed)
493+
* @private
494+
*/
495+
export async function computeWebhookSignature(
496+
body: string,
497+
headers: {
498+
[key: string]: string;
499+
},
500+
signatureKey: string,
501+
): Promise<string | null> {
502+
const escapedBody = jsonStringifyWithEscapedUnicode(body).replace(
503+
/\//g,
504+
'\\/',
505+
);
506+
if (headers['box-signature-version'] !== '1') {
507+
return null;
508+
}
509+
if (headers['box-signature-algorithm'] !== 'HmacSHA256') {
510+
return null;
511+
}
512+
let signature: string | null = null;
513+
if (isBrowser()) {
514+
const hashFunc = createSHA256();
515+
const hmac = await createHMAC(hashFunc, signatureKey);
516+
hmac.init();
517+
hmac.update(escapedBody);
518+
hmac.update(headers['box-delivery-timestamp']);
519+
const result = await hmac.digest('binary');
520+
signature = Buffer.from(result).toString('base64');
521+
} else {
522+
let crypto = eval('require')('crypto');
523+
let hmac = crypto.createHmac('sha256', signatureKey);
524+
hmac.update(escapedBody);
525+
hmac.update(headers['box-delivery-timestamp']);
526+
signature = hmac.digest('base64');
527+
}
528+
return signature;
529+
}

src/managers/webhooks.generated.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { serializeDateTime } from '../internal/utils.js';
2+
import { deserializeDateTime } from '../internal/utils.js';
13
import { serializeWebhooks } from '../schemas/webhooks.generated.js';
24
import { deserializeWebhooks } from '../schemas/webhooks.generated.js';
35
import { serializeClientError } from '../schemas/clientError.generated.js';
46
import { deserializeClientError } from '../schemas/clientError.generated.js';
57
import { serializeWebhook } from '../schemas/webhook.generated.js';
68
import { deserializeWebhook } from '../schemas/webhook.generated.js';
79
import { ResponseFormat } from '../networking/fetchOptions.generated.js';
10+
import { DateTime } from '../internal/utils.js';
811
import { Webhooks } from '../schemas/webhooks.generated.js';
912
import { ClientError } from '../schemas/clientError.generated.js';
1013
import { Webhook } from '../schemas/webhook.generated.js';
@@ -19,6 +22,10 @@ import { ByteStream } from '../internal/utils.js';
1922
import { CancellationToken } from '../internal/utils.js';
2023
import { sdToJson } from '../serialization/json.js';
2124
import { SerializedData } from '../serialization/json.js';
25+
import { computeWebhookSignature } from '../internal/utils.js';
26+
import { dateTimeFromString } from '../internal/utils.js';
27+
import { getEpochTimeInSeconds } from '../internal/utils.js';
28+
import { dateTimeToEpochSeconds } from '../internal/utils.js';
2229
import { sdIsEmpty } from '../serialization/json.js';
2330
import { sdIsBoolean } from '../serialization/json.js';
2431
import { sdIsNumber } from '../serialization/json.js';
@@ -117,6 +124,25 @@ export interface DeleteWebhookByIdOptionalsInput {
117124
readonly headers?: DeleteWebhookByIdHeaders;
118125
readonly cancellationToken?: undefined | CancellationToken;
119126
}
127+
export class ValidateMessageOptionals {
128+
readonly secondaryKey?: string = void 0;
129+
readonly maxAge?: number = 600;
130+
constructor(
131+
fields: Omit<ValidateMessageOptionals, 'secondaryKey' | 'maxAge'> &
132+
Partial<Pick<ValidateMessageOptionals, 'secondaryKey' | 'maxAge'>>,
133+
) {
134+
if (fields.secondaryKey !== undefined) {
135+
this.secondaryKey = fields.secondaryKey;
136+
}
137+
if (fields.maxAge !== undefined) {
138+
this.maxAge = fields.maxAge;
139+
}
140+
}
141+
}
142+
export interface ValidateMessageOptionalsInput {
143+
readonly secondaryKey?: undefined | string;
144+
readonly maxAge?: undefined | number;
145+
}
120146
export interface GetWebhooksQueryParams {
121147
/**
122148
* Defines the position marker at which to begin returning results. This is
@@ -388,6 +414,7 @@ export class WebhooksManager {
388414
| 'getWebhookById'
389415
| 'updateWebhookById'
390416
| 'deleteWebhookById'
417+
| 'validateMessage'
391418
> &
392419
Partial<Pick<WebhooksManager, 'networkSession'>>,
393420
) {
@@ -615,6 +642,56 @@ export class WebhooksManager {
615642
);
616643
return void 0;
617644
}
645+
/**
646+
* Validate a webhook message by verifying the signature and the delivery timestamp
647+
* @param {string} body The request body of the webhook message
648+
* @param {{
649+
readonly [key: string]: string;
650+
}} headers The headers of the webhook message
651+
* @param {string} primaryKey The primary signature to verify the message with
652+
* @param {ValidateMessageOptionalsInput} optionalsInput
653+
* @returns {Promise<boolean>}
654+
*/
655+
static async validateMessage(
656+
body: string,
657+
headers: {
658+
readonly [key: string]: string;
659+
},
660+
primaryKey: string,
661+
optionalsInput: ValidateMessageOptionalsInput = {},
662+
): Promise<boolean> {
663+
const optionals: ValidateMessageOptionals = new ValidateMessageOptionals({
664+
secondaryKey: optionalsInput.secondaryKey,
665+
maxAge: optionalsInput.maxAge,
666+
});
667+
const secondaryKey: any = optionals.secondaryKey;
668+
const maxAge: any = optionals.maxAge;
669+
const deliveryTimestamp: DateTime = dateTimeFromString(
670+
headers['box-delivery-timestamp'],
671+
);
672+
const currentEpoch: number = getEpochTimeInSeconds();
673+
if (
674+
currentEpoch - maxAge > dateTimeToEpochSeconds(deliveryTimestamp) ||
675+
dateTimeToEpochSeconds(deliveryTimestamp) > currentEpoch
676+
) {
677+
return false;
678+
}
679+
if (
680+
primaryKey &&
681+
(await computeWebhookSignature(body, headers, primaryKey)) ==
682+
headers['box-signature-primary']
683+
) {
684+
return true;
685+
}
686+
if (
687+
secondaryKey &&
688+
(await computeWebhookSignature(body, headers, secondaryKey)) ==
689+
headers['box-signature-secondary']
690+
) {
691+
return true;
692+
}
693+
return false;
694+
}
618695
}
619696
export interface WebhooksManagerInput {
620697
readonly auth?: Authentication;

src/networking/boxNetworkClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async function createRequestInit(options: FetchOptions): Promise<RequestInit> {
5050

5151
const { contentHeaders = {}, body } = await (async (): Promise<{
5252
contentHeaders: { [key: string]: string };
53-
body: Readable | string | ArrayBuffer;
53+
body: Readable | string | Buffer;
5454
}> => {
5555
const contentHeaders: { [key: string]: string } = {};
5656
if (options.multipartData) {

0 commit comments

Comments
 (0)