diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 90cdcb016..c2fdbc6e8 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -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'; @@ -63,7 +68,7 @@ export class Actions { payload: responsePayload, signature: await this.computeSignature( responsePayload.timestamp, - responsePayload, + responsePayload as unknown as Record, secret, ), }; @@ -77,7 +82,7 @@ export class Actions { secret, tolerance = 30000, }: { - payload: unknown; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; @@ -85,6 +90,11 @@ export class Actions { 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); } } diff --git a/src/common/crypto/decode-payload.ts b/src/common/crypto/decode-payload.ts new file mode 100644 index 000000000..3d78fb9a1 --- /dev/null +++ b/src/common/crypto/decode-payload.ts @@ -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); +} diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index 7168d0db7..06bec8d29 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -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; @@ -15,7 +16,7 @@ export class SignatureProvider { secret, tolerance = 180000, }: { - payload: any; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; @@ -63,11 +64,11 @@ export class SignatureProvider { async computeSignature( timestamp: any, - payload: any, + payload: WebhookPayload, secret: string, ): Promise { - payload = JSON.stringify(payload); - const signedPayload = `${timestamp}.${payload}`; + const signable = decodePayloadToString(payload); + const signedPayload = `${timestamp}.${signable}`; return await this.cryptoProvider.computeHMACSignatureAsync( signedPayload, diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 2ce5b9efa..651762199 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -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, + sigHeader, + secret, + }); + + expect(webhook.id).toEqual('wh_123'); + }); + }); }); diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 3fcff8277..2fc33b77b 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -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; +} export class Webhooks { private signatureProvider: SignatureProvider; @@ -31,7 +45,7 @@ export class Webhooks { secret, tolerance = 180000, }: { - payload: Record; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; @@ -39,7 +53,7 @@ export class Webhooks { const options = { payload, sigHeader, secret, tolerance }; await this.verifyHeader(options); - const webhookPayload = payload as unknown as EventResponse; + const webhookPayload = parseVerifiedPayload(payload); return deserializeEvent(webhookPayload); }