Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support webhook message validation (box/box-codegen#631) #455

Merged
merged 7 commits into from
Dec 30, 2024
2 changes: 1 addition & 1 deletion .codegen.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "engineHash": "ce7ab17", "specHash": "6886603", "version": "1.9.0" }
{ "engineHash": "cc6ffb8", "specHash": "6886603", "version": "1.9.0" }
33 changes: 33 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- [Get webhook](#get-webhook)
- [Update webhook](#update-webhook)
- [Remove webhook](#remove-webhook)
- [Validate a webhook message](#validate-a-webhook-message)

## List all webhooks

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

An empty response will be returned when the webhook
was successfully deleted.

## Validate a webhook message

Validate a webhook message by verifying the signature and the delivery timestamp

This operation is performed by calling function `validateMessage`.

```ts
await WebhooksManager.validateMessage(
bodyWithJapanese,
headersWithJapanese,
primaryKey,
{ secondaryKey: secondaryKey } satisfies ValidateMessageOptionalsInput,
);
```

### Arguments

- body `string`
- The request body of the webhook message
- headers `{
readonly [key: string]: string;
}`
- The headers of the webhook message
- primaryKey `string`
- The primary signature to verify the message with
- optionalsInput `ValidateMessageOptionalsInput`
-

### Returns

This function returns a value of type `boolean`.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 69 additions & 1 deletion src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Buffer } from 'buffer';
import type { Readable } from 'stream';
import { v4 as uuidv4 } from 'uuid';
import { SignJWT, importPKCS8 } from 'jose';
import { createSHA1 } from 'hash-wasm';
import { createHMAC, createSHA1, createSHA256 } from 'hash-wasm';

export function isBrowser() {
return (
Expand Down Expand Up @@ -59,6 +59,14 @@ export function dateTimeToString(dateTime: DateTimeWrapper): string {
);
}

export function epochSecondsToDateTime(seconds: number): DateTimeWrapper {
return new DateTimeWrapper(new Date(seconds * 1000));
}

export function dateTimeToEpochSeconds(dateTime: DateTimeWrapper): number {
return Math.floor(dateTime.value.getTime() / 1000);
}

export {
dateToString as serializeDate,
dateFromString as deserializeDate,
Expand Down Expand Up @@ -459,3 +467,63 @@ export function createNull(): null {
export function createCancellationController(): CancellationController {
return new AbortController();
}

/**
* Stringify JSON with escaped multibyte Unicode characters to ensure computed signatures match PHP's default behavior
*
* @param {Object} body - The parsed JSON object
* @returns {string} - Stringified JSON with escaped multibyte Unicode characters
* @private
*/
export function jsonStringifyWithEscapedUnicode(body: string) {
return body.replace(
/[\u007f-\uffff]/g,
(char) => `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}`,
);
}

/**
* Compute the message signature
* @see {@Link https://developer.box.com/en/guides/webhooks/handle/setup-signatures/}
*
* @param {string} body - The request body of the webhook message
* @param {Object} headers - The request headers of the webhook message
* @param {string} signatureKey - The signature to verify the message with
* @returns {?string} - The message signature (or null, if it can't be computed)
* @private
*/
export async function computeWebhookSignature(
body: string,
headers: {
[key: string]: string;
},
signatureKey: string,
): Promise<string | null> {
const escapedBody = jsonStringifyWithEscapedUnicode(body).replace(
Dismissed Show dismissed Hide dismissed
/\//g,
'\\/',
);
if (headers['box-signature-version'] !== '1') {
return null;
}
if (headers['box-signature-algorithm'] !== 'HmacSHA256') {
return null;
}
let signature: string | null = null;
if (isBrowser()) {
const hashFunc = createSHA256();
const hmac = await createHMAC(hashFunc, signatureKey);
hmac.init();
hmac.update(escapedBody);
hmac.update(headers['box-delivery-timestamp']);
const result = await hmac.digest('binary');
signature = Buffer.from(result).toString('base64');
} else {
let crypto = eval('require')('crypto');
let hmac = crypto.createHmac('sha256', signatureKey);
hmac.update(escapedBody);
hmac.update(headers['box-delivery-timestamp']);
signature = hmac.digest('base64');
}
return signature;
}
77 changes: 77 additions & 0 deletions src/managers/webhooks.generated.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { serializeDateTime } from '../internal/utils.js';
import { deserializeDateTime } from '../internal/utils.js';
import { serializeWebhooks } from '../schemas/webhooks.generated.js';
import { deserializeWebhooks } from '../schemas/webhooks.generated.js';
import { serializeClientError } from '../schemas/clientError.generated.js';
import { deserializeClientError } from '../schemas/clientError.generated.js';
import { serializeWebhook } from '../schemas/webhook.generated.js';
import { deserializeWebhook } from '../schemas/webhook.generated.js';
import { ResponseFormat } from '../networking/fetchOptions.generated.js';
import { DateTime } from '../internal/utils.js';
import { Webhooks } from '../schemas/webhooks.generated.js';
import { ClientError } from '../schemas/clientError.generated.js';
import { Webhook } from '../schemas/webhook.generated.js';
Expand All @@ -19,6 +22,10 @@ import { ByteStream } from '../internal/utils.js';
import { CancellationToken } from '../internal/utils.js';
import { sdToJson } from '../serialization/json.js';
import { SerializedData } from '../serialization/json.js';
import { computeWebhookSignature } from '../internal/utils.js';
import { dateTimeFromString } from '../internal/utils.js';
import { getEpochTimeInSeconds } from '../internal/utils.js';
import { dateTimeToEpochSeconds } from '../internal/utils.js';
import { sdIsEmpty } from '../serialization/json.js';
import { sdIsBoolean } from '../serialization/json.js';
import { sdIsNumber } from '../serialization/json.js';
Expand Down Expand Up @@ -117,6 +124,25 @@ export interface DeleteWebhookByIdOptionalsInput {
readonly headers?: DeleteWebhookByIdHeaders;
readonly cancellationToken?: undefined | CancellationToken;
}
export class ValidateMessageOptionals {
readonly secondaryKey?: string = void 0;
readonly maxAge?: number = 600;
constructor(
fields: Omit<ValidateMessageOptionals, 'secondaryKey' | 'maxAge'> &
Partial<Pick<ValidateMessageOptionals, 'secondaryKey' | 'maxAge'>>,
) {
if (fields.secondaryKey !== undefined) {
this.secondaryKey = fields.secondaryKey;
}
if (fields.maxAge !== undefined) {
this.maxAge = fields.maxAge;
}
}
}
export interface ValidateMessageOptionalsInput {
readonly secondaryKey?: undefined | string;
readonly maxAge?: undefined | number;
}
export interface GetWebhooksQueryParams {
/**
* Defines the position marker at which to begin returning results. This is
Expand Down Expand Up @@ -388,6 +414,7 @@ export class WebhooksManager {
| 'getWebhookById'
| 'updateWebhookById'
| 'deleteWebhookById'
| 'validateMessage'
> &
Partial<Pick<WebhooksManager, 'networkSession'>>,
) {
Expand Down Expand Up @@ -615,6 +642,56 @@ export class WebhooksManager {
);
return void 0;
}
/**
* Validate a webhook message by verifying the signature and the delivery timestamp
* @param {string} body The request body of the webhook message
* @param {{
readonly [key: string]: string;
}} headers The headers of the webhook message
* @param {string} primaryKey The primary signature to verify the message with
* @param {ValidateMessageOptionalsInput} optionalsInput
* @returns {Promise<boolean>}
*/
static async validateMessage(
body: string,
headers: {
readonly [key: string]: string;
},
primaryKey: string,
optionalsInput: ValidateMessageOptionalsInput = {},
): Promise<boolean> {
const optionals: ValidateMessageOptionals = new ValidateMessageOptionals({
secondaryKey: optionalsInput.secondaryKey,
maxAge: optionalsInput.maxAge,
});
const secondaryKey: any = optionals.secondaryKey;
const maxAge: any = optionals.maxAge;
const deliveryTimestamp: DateTime = dateTimeFromString(
headers['box-delivery-timestamp'],
);
const currentEpoch: number = getEpochTimeInSeconds();
if (
currentEpoch - maxAge > dateTimeToEpochSeconds(deliveryTimestamp) ||
dateTimeToEpochSeconds(deliveryTimestamp) > currentEpoch
) {
return false;
}
if (
primaryKey &&
(await computeWebhookSignature(body, headers, primaryKey)) ==
headers['box-signature-primary']
) {
return true;
}
if (
secondaryKey &&
(await computeWebhookSignature(body, headers, secondaryKey)) ==
headers['box-signature-secondary']
) {
return true;
}
return false;
}
}
export interface WebhooksManagerInput {
readonly auth?: Authentication;
Expand Down
2 changes: 1 addition & 1 deletion src/networking/boxNetworkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async function createRequestInit(options: FetchOptions): Promise<RequestInit> {

const { contentHeaders = {}, body } = await (async (): Promise<{
contentHeaders: { [key: string]: string };
body: Readable | string | ArrayBuffer;
body: Readable | string | Buffer;
}> => {
const contentHeaders: { [key: string]: string } = {};
if (options.multipartData) {
Expand Down
Loading
Loading