Skip to content
16 changes: 13 additions & 3 deletions src/actions/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// @oagen-ignore-file
import { CryptoProvider } from '../common/crypto/crypto-provider';
import {
type WebhookPayload,
decodePayloadToString,
isBinaryPayload,
} from '../common/crypto/decode-payload';
import { SignatureProvider } from '../common/crypto/signature-provider';
import { unreachable } from '../common/utils/unreachable';
import { ActionContext, ActionPayload } from './interfaces/action.interface';
Expand Down Expand Up @@ -63,7 +68,7 @@ export class Actions {
payload: responsePayload,
signature: await this.computeSignature(
responsePayload.timestamp,
responsePayload,
responsePayload as unknown as Record<string, unknown>,
secret,
),
};
Expand All @@ -77,14 +82,19 @@ export class Actions {
secret,
tolerance = 30000,
}: {
payload: unknown;
payload: WebhookPayload;
sigHeader: string;
secret: string;
tolerance?: number;
}): Promise<ActionContext> {
const options = { payload, sigHeader, secret, tolerance };
await this.verifyHeader(options);

return deserializeAction(payload as ActionPayload);
const parsed: ActionPayload =
typeof payload === 'string' || isBinaryPayload(payload)
? (JSON.parse(decodePayloadToString(payload)) as ActionPayload)
: (payload as unknown as ActionPayload);

return deserializeAction(parsed);
}
}
27 changes: 27 additions & 0 deletions src/common/crypto/decode-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type WebhookPayload = string | Uint8Array | ArrayBuffer | object;

// Realm-agnostic check for binary payloads. `instanceof` fails when the
// value originates from a different JS realm (Workers, iframes, VM contexts).
export function isBinaryPayload(payload: WebhookPayload): boolean {
return (
ArrayBuffer.isView(payload) ||
Object.prototype.toString.call(payload) === '[object ArrayBuffer]'
);
}

// Decodes raw byte payloads to a UTF-8 string without stripping a BOM prefix.
// Used by both signature verification (HMAC input) and post-verification
// parsing (JSON.parse input) to guarantee the same bytes are signed and parsed.
export function decodePayloadToString(payload: WebhookPayload): string {
if (typeof payload === 'string') {
return payload;
}
if (isBinaryPayload(payload)) {
const bytes =
Object.prototype.toString.call(payload) === '[object ArrayBuffer]'
? new Uint8Array(payload as ArrayBuffer)
: (payload as Uint8Array);
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes);
}
return JSON.stringify(payload);
}
9 changes: 5 additions & 4 deletions src/common/crypto/signature-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @oagen-ignore-file
import { SignatureVerificationException } from '../exceptions';
import { CryptoProvider } from './crypto-provider';
import { type WebhookPayload, decodePayloadToString } from './decode-payload';

export class SignatureProvider {
private cryptoProvider: CryptoProvider;
Expand All @@ -15,7 +16,7 @@ export class SignatureProvider {
secret,
tolerance = 180000,
}: {
payload: any;
payload: WebhookPayload;
sigHeader: string;
secret: string;
tolerance?: number;
Expand Down Expand Up @@ -63,11 +64,11 @@ export class SignatureProvider {

async computeSignature(
timestamp: any,
payload: any,
payload: WebhookPayload,
secret: string,
): Promise<string> {
payload = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payload}`;
const signable = decodePayloadToString(payload);
const signedPayload = `${timestamp}.${signable}`;

return await this.cryptoProvider.computeHMACSignatureAsync(
signedPayload,
Expand Down
160 changes: 160 additions & 0 deletions src/webhooks/webhooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,164 @@ describe('Webhooks', () => {
expect(spy).toHaveBeenCalled();
});
});

describe('raw-bytes payload path (non-breaking overload)', () => {
// Sign the exact bytes WorkOS would send on the wire. The SDK should HMAC
// those same bytes (not a re-stringified round-trip), matching the pattern
// used by Stripe, GitHub, and Svix.
const signRaw = (rawBody: string, ts: number, sec: string): string =>
crypto.createHmac('sha256', sec).update(`${ts}.${rawBody}`).digest('hex');

it('verifies when payload is a raw JSON string matching the signed bytes', async () => {
const rawBody = JSON.stringify(mockWebhook);
const hash = signRaw(rawBody, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

const webhook = await workos.webhooks.constructEvent({
payload: rawBody,
sigHeader,
secret,
});

expect(webhook.id).toEqual('wh_123');
});

it('verifies when payload is a Buffer matching the signed bytes', async () => {
const rawBody = JSON.stringify(mockWebhook);
const hash = signRaw(rawBody, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

const webhook = await workos.webhooks.constructEvent({
payload: Buffer.from(rawBody, 'utf-8'),
sigHeader,
secret,
});

expect(webhook.id).toEqual('wh_123');
});

it('verifies when payload is a Uint8Array matching the signed bytes', async () => {
const rawBody = JSON.stringify(mockWebhook);
const hash = signRaw(rawBody, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;
const bytes = new TextEncoder().encode(rawBody);

const webhook = await workos.webhooks.constructEvent({
payload: bytes,
sigHeader,
secret,
});

expect(webhook.id).toEqual('wh_123');
});

it('verifies when payload is an ArrayBuffer matching the signed bytes', async () => {
const rawBody = JSON.stringify(mockWebhook);
const hash = signRaw(rawBody, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;
const bytes = new TextEncoder().encode(rawBody);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);

const webhook = await workos.webhooks.constructEvent({
payload: arrayBuffer,
sigHeader,
secret,
});

expect(webhook.id).toEqual('wh_123');
});

it('rejects mutated bytes that round-trip through JSON.parse to the same object (whitespace)', async () => {
// Server signs canonical bytes; attacker forwards bytes with injected
// whitespace that parse to the same object.
const signedBytes = JSON.stringify(mockWebhook);
const mutatedBytes = JSON.stringify(mockWebhook, null, 2); // pretty-printed
expect(JSON.parse(signedBytes)).toEqual(JSON.parse(mutatedBytes));
expect(signedBytes).not.toEqual(mutatedBytes);

const hash = signRaw(signedBytes, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

await expect(
workos.webhooks.constructEvent({
payload: mutatedBytes,
sigHeader,
secret,
}),
).rejects.toThrow(SignatureVerificationException);
});

it('rejects mutated bytes with unicode-escaped characters (same parsed object)', async () => {
const signedBytes =
'{"event":"dsync.user.created","data":{"name":"hello"}}';
// hello parses to "hello" — same object, different bytes
const mutatedBytes =
'{"event":"dsync.user.created","data":{"name":"\\u0068\\u0065\\u006c\\u006c\\u006f"}}';
expect(JSON.parse(signedBytes)).toEqual(JSON.parse(mutatedBytes));
expect(signedBytes).not.toEqual(mutatedBytes);

const hash = signRaw(signedBytes, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

await expect(
workos.webhooks.constructEvent({
payload: mutatedBytes,
sigHeader,
secret,
}),
).rejects.toThrow(SignatureVerificationException);
});

it('rejects mutated bytes with reordered keys (same parsed object)', async () => {
const signedBytes = '{"a":1,"b":2}';
const mutatedBytes = '{"b":2,"a":1}';
expect(JSON.parse(signedBytes)).toEqual(JSON.parse(mutatedBytes));

const hash = signRaw(signedBytes, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

await expect(
workos.webhooks.verifyHeader({
payload: mutatedBytes,
sigHeader,
secret,
}),
).rejects.toThrow(SignatureVerificationException);
});

it('rejects Buffer with a prepended UTF-8 BOM', async () => {
const signedBytes = JSON.stringify(mockWebhook);
const bomBuffer = Buffer.concat([
Buffer.from([0xef, 0xbb, 0xbf]),
Buffer.from(signedBytes, 'utf-8'),
]);

const hash = signRaw(signedBytes, timestamp, secret);
const sigHeader = `t=${timestamp}, v1=${hash}`;

await expect(
workos.webhooks.constructEvent({
payload: bomBuffer,
sigHeader,
secret,
}),
).rejects.toThrow(SignatureVerificationException);
});

it('legacy object path still verifies (back-compat)', async () => {
// Existing caller shape: pre-parsed object, SDK re-stringifies internally.
// Unsafe to byte-level mutation but must keep working until we deprecate.
const sigHeader = `t=${timestamp}, v1=${signatureHash}`;
const webhook = await workos.webhooks.constructEvent({
payload: mockWebhook as unknown as Record<string, unknown>,
sigHeader,
secret,
});

expect(webhook.id).toEqual('wh_123');
});
});
});
18 changes: 16 additions & 2 deletions src/webhooks/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { deserializeEvent } from '../common/serializers';
import { Event, EventResponse } from '../common/interfaces';
import { SignatureProvider } from '../common/crypto/signature-provider';
import { CryptoProvider } from '../common/crypto/crypto-provider';
import {
type WebhookPayload,
decodePayloadToString,
isBinaryPayload,
} from '../common/crypto/decode-payload';

// Parse only after verification succeeds — a malformed body never reaches
// JSON.parse on an unauthenticated request.
function parseVerifiedPayload(payload: WebhookPayload): EventResponse {
if (typeof payload === 'object' && !isBinaryPayload(payload)) {
return payload as unknown as EventResponse;
}
return JSON.parse(decodePayloadToString(payload)) as EventResponse;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export class Webhooks {
private signatureProvider: SignatureProvider;
Expand Down Expand Up @@ -31,15 +45,15 @@ export class Webhooks {
secret,
tolerance = 180000,
}: {
payload: Record<string, unknown>;
payload: WebhookPayload;
sigHeader: string;
secret: string;
tolerance?: number;
}): Promise<Event> {
const options = { payload, sigHeader, secret, tolerance };
await this.verifyHeader(options);

const webhookPayload = payload as unknown as EventResponse;
const webhookPayload = parseVerifiedPayload(payload);

return deserializeEvent(webhookPayload);
}
Expand Down
Loading