From 657817394782ac45bcf0bf56ec41789f082146d0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Apr 2026 20:37:51 -0700 Subject: [PATCH 1/9] fix(webhooks): accept raw request bytes for signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens constructEvent/verifyHeader payload to string | Uint8Array | ArrayBuffer | object. Raw-bytes paths HMAC the exact bytes the server signed; object path keeps the legacy JSON.stringify behavior for back-compat. constructEvent now parses after verification on the raw paths, matching the pattern used by Stripe, GitHub, and Svix. Hardening only — the object path remains unsafe to on-the-wire mutations that round-trip through JSON.parse/JSON.stringify to the same canonical form (whitespace, key order, unicode escapes, duplicate keys). Callers should migrate to passing the raw body. --- src/common/crypto/signature-provider.ts | 37 ++++++- src/webhooks/webhooks.spec.ts | 124 ++++++++++++++++++++++++ src/webhooks/webhooks.ts | 31 +++++- 3 files changed, 186 insertions(+), 6 deletions(-) diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index 7168d0db7..b2edf2b66 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -15,7 +15,16 @@ export class SignatureProvider { secret, tolerance = 180000, }: { - payload: any; + // Accepts raw request bytes (string/Uint8Array/Buffer) — preferred — or a + // parsed object for back-compat. Raw-bytes path HMACs the exact bytes the + // server signed; object path falls back to JSON.stringify (unsafe to + // mutation that round-trips through JSON.parse → JSON.stringify). + payload: + | string + | Uint8Array + | ArrayBuffer + | Record + | unknown; sigHeader: string; secret: string; tolerance?: number; @@ -63,11 +72,11 @@ export class SignatureProvider { async computeSignature( timestamp: any, - payload: any, + payload: string | Uint8Array | ArrayBuffer | Record | unknown, secret: string, ): Promise { - payload = JSON.stringify(payload); - const signedPayload = `${timestamp}.${payload}`; + const signable = toSignableString(payload); + const signedPayload = `${timestamp}.${signable}`; return await this.cryptoProvider.computeHMACSignatureAsync( signedPayload, @@ -75,3 +84,23 @@ export class SignatureProvider { ); } } + +// Raw bytes path (string / Uint8Array / ArrayBuffer) reproduces the exact +// bytes WorkOS signed on emit. Object path is legacy back-compat — vulnerable +// to any on-the-wire mutation that round-trips through JSON.parse → +// JSON.stringify to the same canonical form (whitespace, key order, \uXXXX +// escapes, duplicate keys). +function toSignableString( + payload: string | Uint8Array | ArrayBuffer | Record | unknown, +): string { + if (typeof payload === 'string') { + return payload; + } + if (payload instanceof Uint8Array) { + return new TextDecoder('utf-8').decode(payload); + } + if (payload instanceof ArrayBuffer) { + return new TextDecoder('utf-8').decode(new Uint8Array(payload)); + } + return JSON.stringify(payload); +} diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 2ce5b9efa..19261e1f6 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -210,4 +210,128 @@ 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('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('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..6bcea39e5 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -4,6 +4,25 @@ import { Event, EventResponse } from '../common/interfaces'; import { SignatureProvider } from '../common/crypto/signature-provider'; import { CryptoProvider } from '../common/crypto/crypto-provider'; +// Parse only after verification succeeds — a malformed body never reaches +// JSON.parse on an unauthenticated request. +function parseVerifiedPayload( + payload: string | Uint8Array | ArrayBuffer | Record, +): EventResponse { + if (typeof payload === 'string') { + return JSON.parse(payload) as EventResponse; + } + if (payload instanceof Uint8Array) { + return JSON.parse(new TextDecoder('utf-8').decode(payload)) as EventResponse; + } + if (payload instanceof ArrayBuffer) { + return JSON.parse( + new TextDecoder('utf-8').decode(new Uint8Array(payload)), + ) as EventResponse; + } + return payload as unknown as EventResponse; +} + export class Webhooks { private signatureProvider: SignatureProvider; @@ -31,7 +50,15 @@ export class Webhooks { secret, tolerance = 180000, }: { - payload: Record; + // Prefer raw request bytes (string / Uint8Array / Buffer / ArrayBuffer) so + // the HMAC is computed over the exact bytes WorkOS signed. Parsed objects + // are still accepted for back-compat but fall through a JSON.stringify + // round-trip that can disagree with the on-the-wire bytes. + payload: + | string + | Uint8Array + | ArrayBuffer + | Record; sigHeader: string; secret: string; tolerance?: number; @@ -39,7 +66,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); } From d16dbdf6680775f5e43cb45f2f1fce267bed9ea0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 30 Apr 2026 22:18:10 -0500 Subject: [PATCH 2/9] chore: formatting --- src/common/crypto/signature-provider.ts | 14 ++++++++++++-- src/webhooks/webhooks.spec.ts | 8 +++----- src/webhooks/webhooks.ts | 10 ++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index b2edf2b66..85acefaa5 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -72,7 +72,12 @@ export class SignatureProvider { async computeSignature( timestamp: any, - payload: string | Uint8Array | ArrayBuffer | Record | unknown, + payload: + | string + | Uint8Array + | ArrayBuffer + | Record + | unknown, secret: string, ): Promise { const signable = toSignableString(payload); @@ -91,7 +96,12 @@ export class SignatureProvider { // JSON.stringify to the same canonical form (whitespace, key order, \uXXXX // escapes, duplicate keys). function toSignableString( - payload: string | Uint8Array | ArrayBuffer | Record | unknown, + payload: + | string + | Uint8Array + | ArrayBuffer + | Record + | unknown, ): string { if (typeof payload === 'string') { return payload; diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 19261e1f6..092475ef8 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -216,10 +216,7 @@ describe('Webhooks', () => { // 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'); + 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); @@ -285,7 +282,8 @@ describe('Webhooks', () => { }); it('rejects mutated bytes with unicode-escaped characters (same parsed object)', async () => { - const signedBytes = '{"event":"dsync.user.created","data":{"name":"hello"}}'; + 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"}}'; diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 6bcea39e5..8531e6d81 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -13,7 +13,9 @@ function parseVerifiedPayload( return JSON.parse(payload) as EventResponse; } if (payload instanceof Uint8Array) { - return JSON.parse(new TextDecoder('utf-8').decode(payload)) as EventResponse; + return JSON.parse( + new TextDecoder('utf-8').decode(payload), + ) as EventResponse; } if (payload instanceof ArrayBuffer) { return JSON.parse( @@ -54,11 +56,7 @@ export class Webhooks { // the HMAC is computed over the exact bytes WorkOS signed. Parsed objects // are still accepted for back-compat but fall through a JSON.stringify // round-trip that can disagree with the on-the-wire bytes. - payload: - | string - | Uint8Array - | ArrayBuffer - | Record; + payload: string | Uint8Array | ArrayBuffer | Record; sigHeader: string; secret: string; tolerance?: number; From a20bd27ef7031991eb7c213ea4d07e896a8292a4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 1 May 2026 12:47:37 -0500 Subject: [PATCH 3/9] fix(webhooks): preserve BOM bytes in raw-payload decoding TextDecoder strips a leading UTF-8 BOM (0xEF 0xBB 0xBF) by default. An attacker could prepend BOM bytes to a Buffer/Uint8Array payload and pass signature verification because the HMAC was computed on the BOM-stripped string. Use { ignoreBOM: true } to preserve all bytes. --- src/common/crypto/signature-provider.ts | 6 ++++-- src/webhooks/webhooks.spec.ts | 19 +++++++++++++++++++ src/webhooks/webhooks.ts | 6 ++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index 85acefaa5..c107fa7ee 100644 --- a/src/common/crypto/signature-provider.ts +++ b/src/common/crypto/signature-provider.ts @@ -107,10 +107,12 @@ function toSignableString( return payload; } if (payload instanceof Uint8Array) { - return new TextDecoder('utf-8').decode(payload); + return new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload); } if (payload instanceof ArrayBuffer) { - return new TextDecoder('utf-8').decode(new Uint8Array(payload)); + return new TextDecoder('utf-8', { ignoreBOM: true }).decode( + new Uint8Array(payload), + ); } return JSON.stringify(payload); } diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 092475ef8..44294bac3 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -319,6 +319,25 @@ describe('Webhooks', () => { ).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. diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 8531e6d81..0f9efb4fb 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -14,12 +14,14 @@ function parseVerifiedPayload( } if (payload instanceof Uint8Array) { return JSON.parse( - new TextDecoder('utf-8').decode(payload), + new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload), ) as EventResponse; } if (payload instanceof ArrayBuffer) { return JSON.parse( - new TextDecoder('utf-8').decode(new Uint8Array(payload)), + new TextDecoder('utf-8', { ignoreBOM: true }).decode( + new Uint8Array(payload), + ), ) as EventResponse; } return payload as unknown as EventResponse; From 151b99f6a6ac831587733028f2208e8aa7eee951 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 1 May 2026 16:02:34 -0500 Subject: [PATCH 4/9] refactor(webhooks): extract shared payload decoder, drop | unknown Addresses PR review feedback: - Remove `| unknown` from verifyHeader/computeSignature unions so TypeScript actually enforces the payload type at call sites. - Extract `decodePayloadToString` and `WebhookPayload` type into a shared module to eliminate duplicated decode logic between signature-provider.ts and webhooks.ts. - Add missing ArrayBuffer test case. - Fix actions.ts call sites surfaced by the tighter types. --- src/actions/actions.ts | 7 ++-- src/common/crypto/decode-payload.ts | 23 ++++++++++++ src/common/crypto/signature-provider.ts | 48 +++---------------------- src/webhooks/webhooks.spec.ts | 19 ++++++++++ src/webhooks/webhooks.ts | 32 +++++------------ 5 files changed, 59 insertions(+), 70 deletions(-) create mode 100644 src/common/crypto/decode-payload.ts diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 90cdcb016..5b8ebc267 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -1,5 +1,6 @@ // @oagen-ignore-file import { CryptoProvider } from '../common/crypto/crypto-provider'; +import { type WebhookPayload } 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 +64,7 @@ export class Actions { payload: responsePayload, signature: await this.computeSignature( responsePayload.timestamp, - responsePayload, + responsePayload as unknown as Record, secret, ), }; @@ -77,7 +78,7 @@ export class Actions { secret, tolerance = 30000, }: { - payload: unknown; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; @@ -85,6 +86,6 @@ export class Actions { const options = { payload, sigHeader, secret, tolerance }; await this.verifyHeader(options); - return deserializeAction(payload as ActionPayload); + return deserializeAction(payload as unknown as ActionPayload); } } diff --git a/src/common/crypto/decode-payload.ts b/src/common/crypto/decode-payload.ts new file mode 100644 index 000000000..c451d443c --- /dev/null +++ b/src/common/crypto/decode-payload.ts @@ -0,0 +1,23 @@ +export type WebhookPayload = + | string + | Uint8Array + | ArrayBuffer + | Record; + +// 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 (payload instanceof ArrayBuffer) { + return new TextDecoder('utf-8', { ignoreBOM: true }).decode( + new Uint8Array(payload), + ); + } + if (payload instanceof Uint8Array) { + return new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload); + } + return JSON.stringify(payload); +} diff --git a/src/common/crypto/signature-provider.ts b/src/common/crypto/signature-provider.ts index c107fa7ee..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,16 +16,7 @@ export class SignatureProvider { secret, tolerance = 180000, }: { - // Accepts raw request bytes (string/Uint8Array/Buffer) — preferred — or a - // parsed object for back-compat. Raw-bytes path HMACs the exact bytes the - // server signed; object path falls back to JSON.stringify (unsafe to - // mutation that round-trips through JSON.parse → JSON.stringify). - payload: - | string - | Uint8Array - | ArrayBuffer - | Record - | unknown; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; @@ -72,15 +64,10 @@ export class SignatureProvider { async computeSignature( timestamp: any, - payload: - | string - | Uint8Array - | ArrayBuffer - | Record - | unknown, + payload: WebhookPayload, secret: string, ): Promise { - const signable = toSignableString(payload); + const signable = decodePayloadToString(payload); const signedPayload = `${timestamp}.${signable}`; return await this.cryptoProvider.computeHMACSignatureAsync( @@ -89,30 +76,3 @@ export class SignatureProvider { ); } } - -// Raw bytes path (string / Uint8Array / ArrayBuffer) reproduces the exact -// bytes WorkOS signed on emit. Object path is legacy back-compat — vulnerable -// to any on-the-wire mutation that round-trips through JSON.parse → -// JSON.stringify to the same canonical form (whitespace, key order, \uXXXX -// escapes, duplicate keys). -function toSignableString( - payload: - | string - | Uint8Array - | ArrayBuffer - | Record - | unknown, -): string { - if (typeof payload === 'string') { - return payload; - } - if (payload instanceof Uint8Array) { - return new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload); - } - if (payload instanceof ArrayBuffer) { - return new TextDecoder('utf-8', { ignoreBOM: true }).decode( - new Uint8Array(payload), - ); - } - return JSON.stringify(payload); -} diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 44294bac3..651762199 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -261,6 +261,25 @@ describe('Webhooks', () => { 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. diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 0f9efb4fb..0dbd79225 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -3,28 +3,18 @@ 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, +} from '../common/crypto/decode-payload'; // Parse only after verification succeeds — a malformed body never reaches // JSON.parse on an unauthenticated request. -function parseVerifiedPayload( - payload: string | Uint8Array | ArrayBuffer | Record, -): EventResponse { - if (typeof payload === 'string') { - return JSON.parse(payload) as EventResponse; +function parseVerifiedPayload(payload: WebhookPayload): EventResponse { + if (typeof payload === 'object' && !(payload instanceof Uint8Array) && !(payload instanceof ArrayBuffer)) { + return payload as unknown as EventResponse; } - if (payload instanceof Uint8Array) { - return JSON.parse( - new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload), - ) as EventResponse; - } - if (payload instanceof ArrayBuffer) { - return JSON.parse( - new TextDecoder('utf-8', { ignoreBOM: true }).decode( - new Uint8Array(payload), - ), - ) as EventResponse; - } - return payload as unknown as EventResponse; + return JSON.parse(decodePayloadToString(payload)) as EventResponse; } export class Webhooks { @@ -54,11 +44,7 @@ export class Webhooks { secret, tolerance = 180000, }: { - // Prefer raw request bytes (string / Uint8Array / Buffer / ArrayBuffer) so - // the HMAC is computed over the exact bytes WorkOS signed. Parsed objects - // are still accepted for back-compat but fall through a JSON.stringify - // round-trip that can disagree with the on-the-wire bytes. - payload: string | Uint8Array | ArrayBuffer | Record; + payload: WebhookPayload; sigHeader: string; secret: string; tolerance?: number; From 17ca7a9f1abb653ec11232bc73de77ac77d5cea9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 1 May 2026 16:15:48 -0500 Subject: [PATCH 5/9] fix(actions): parse raw-bytes payloads before deserialization constructAction now accepts WebhookPayload (string | Uint8Array | ArrayBuffer | Record) but was casting directly to ActionPayload without parsing. Raw-bytes payloads would pass verification but silently fail deserialization. Parse via decodePayloadToString + JSON.parse for non-object payloads, matching the webhook pattern. --- src/actions/actions.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 5b8ebc267..0dcd68414 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -1,6 +1,9 @@ // @oagen-ignore-file import { CryptoProvider } from '../common/crypto/crypto-provider'; -import { type WebhookPayload } from '../common/crypto/decode-payload'; +import { + type WebhookPayload, + decodePayloadToString, +} 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'; @@ -86,6 +89,13 @@ export class Actions { const options = { payload, sigHeader, secret, tolerance }; await this.verifyHeader(options); - return deserializeAction(payload as unknown as ActionPayload); + const parsed: ActionPayload = + typeof payload === 'string' || + payload instanceof Uint8Array || + payload instanceof ArrayBuffer + ? (JSON.parse(decodePayloadToString(payload)) as ActionPayload) + : (payload as unknown as ActionPayload); + + return deserializeAction(parsed); } } From 92b1e79006043a523205fa9f30c5c30488185b71 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 1 May 2026 16:21:40 -0500 Subject: [PATCH 6/9] chore: formatting --- src/webhooks/webhooks.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 0dbd79225..0517bbf1a 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -11,7 +11,11 @@ import { // 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' && !(payload instanceof Uint8Array) && !(payload instanceof ArrayBuffer)) { + if ( + typeof payload === 'object' && + !(payload instanceof Uint8Array) && + !(payload instanceof ArrayBuffer) + ) { return payload as unknown as EventResponse; } return JSON.parse(decodePayloadToString(payload)) as EventResponse; From 15d6ccfe1d32c4bb9e8d0a4d3a6397f44594bf1a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 21:28:27 +0000 Subject: [PATCH 7/9] fix(webhooks): use realm-agnostic binary payload detection Replace instanceof Uint8Array/ArrayBuffer checks with ArrayBuffer.isView() and Object.prototype.toString.call() to handle payloads from different JS realms (Workers, iframes, VM contexts). Extract shared isBinaryPayload() helper into decode-payload.ts and use it consistently in webhooks.ts and actions.ts. Co-Authored-By: nick.nisi@workos.com --- src/actions/actions.ts | 5 ++--- src/common/crypto/decode-payload.ts | 22 +++++++++++++++------- src/webhooks/webhooks.ts | 7 ++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 0dcd68414..c2fdbc6e8 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -3,6 +3,7 @@ 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'; @@ -90,9 +91,7 @@ export class Actions { await this.verifyHeader(options); const parsed: ActionPayload = - typeof payload === 'string' || - payload instanceof Uint8Array || - payload instanceof ArrayBuffer + typeof payload === 'string' || isBinaryPayload(payload) ? (JSON.parse(decodePayloadToString(payload)) as ActionPayload) : (payload as unknown as ActionPayload); diff --git a/src/common/crypto/decode-payload.ts b/src/common/crypto/decode-payload.ts index c451d443c..903e150ad 100644 --- a/src/common/crypto/decode-payload.ts +++ b/src/common/crypto/decode-payload.ts @@ -4,6 +4,15 @@ export type WebhookPayload = | ArrayBuffer | Record; +// 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. @@ -11,13 +20,12 @@ export function decodePayloadToString(payload: WebhookPayload): string { if (typeof payload === 'string') { return payload; } - if (payload instanceof ArrayBuffer) { - return new TextDecoder('utf-8', { ignoreBOM: true }).decode( - new Uint8Array(payload), - ); - } - if (payload instanceof Uint8Array) { - return new TextDecoder('utf-8', { ignoreBOM: true }).decode(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/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index 0517bbf1a..2fc33b77b 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -6,16 +6,13 @@ 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' && - !(payload instanceof Uint8Array) && - !(payload instanceof ArrayBuffer) - ) { + if (typeof payload === 'object' && !isBinaryPayload(payload)) { return payload as unknown as EventResponse; } return JSON.parse(decodePayloadToString(payload)) as EventResponse; From f4cd6373a15c52c16066cdd33a5ec88254e074ed Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 12:04:26 -0700 Subject: [PATCH 8/9] fix(webhooks): widen WebhookPayload to accept any object type Record rejects typed interfaces without an index signature, breaking existing callers that pass strongly-typed parsed objects to constructAction/verifyHeader. Use `object` instead so any non-primitive passes without requiring a cast. --- src/common/crypto/decode-payload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/crypto/decode-payload.ts b/src/common/crypto/decode-payload.ts index 903e150ad..63d9170eb 100644 --- a/src/common/crypto/decode-payload.ts +++ b/src/common/crypto/decode-payload.ts @@ -2,7 +2,7 @@ export type WebhookPayload = | string | Uint8Array | ArrayBuffer - | Record; + | object; // Realm-agnostic check for binary payloads. `instanceof` fails when the // value originates from a different JS realm (Workers, iframes, VM contexts). From 84a4da83bbbb44b477edc9b816aa9dd34b08dd67 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 6 May 2026 12:11:41 -0700 Subject: [PATCH 9/9] chore: formatting --- src/common/crypto/decode-payload.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/common/crypto/decode-payload.ts b/src/common/crypto/decode-payload.ts index 63d9170eb..3d78fb9a1 100644 --- a/src/common/crypto/decode-payload.ts +++ b/src/common/crypto/decode-payload.ts @@ -1,8 +1,4 @@ -export type WebhookPayload = - | string - | Uint8Array - | ArrayBuffer - | object; +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).