Skip to content

Commit c8abf47

Browse files
committed
Add verifyWebhookSignature helper for signed webhooks
Verifies the x-fishjam-signature-256 header (sha256=<hex> HMAC-SHA256 of the raw body) with a constant-time compare.
1 parent 742dae6 commit c8abf47

3 files changed

Lines changed: 72 additions & 2 deletions

File tree

packages/js-server-sdk/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export {
2323

2424
export { ServerMessage } from '@fishjam-cloud/fishjam-proto';
2525
export { FishjamWSNotifier } from './ws_notifier';
26-
export { decodeServerNotifications } from './webhook';
26+
export { decodeServerNotifications, verifyWebhookSignature } from './webhook';
2727
export type {
2828
Track,
2929
ServerNotification,

packages/js-server-sdk/src/webhook.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHmac, timingSafeEqual } from 'node:crypto';
2+
13
import { ServerMessage } from '@fishjam-cloud/fishjam-proto';
24
import { extractNotifications, ServerNotification } from './notifications';
35

@@ -30,3 +32,39 @@ import { extractNotifications, ServerNotification } from './notifications';
3032
*/
3133
export const decodeServerNotifications = (data: Uint8Array | ArrayBuffer): ServerNotification[] =>
3234
extractNotifications(ServerMessage.decode(data instanceof Uint8Array ? data : new Uint8Array(data)));
35+
36+
/**
37+
* Verify the signature of a raw Fishjam webhook request.
38+
*
39+
* Fishjam signs each webhook delivery with the room's signing secret and sends
40+
* the result in the `x-fishjam-signature-256` header as
41+
* `sha256=<lowercase hex HMAC-SHA256 of the raw body>`. Pass the raw
42+
* (undecoded) request body, the header value, and your secret; the comparison
43+
* is constant-time. Verify before calling {@link decodeServerNotifications}.
44+
*
45+
* @example
46+
* ```ts
47+
* import { verifyWebhookSignature, decodeServerNotifications } from '@fishjam-cloud/js-server-sdk';
48+
*
49+
* declare const body: Uint8Array;
50+
* declare const signatureHeader: string;
51+
* // ---cut---
52+
* if (!verifyWebhookSignature(body, signatureHeader, process.env.WEBHOOK_SECRET!)) {
53+
* throw new Error('Invalid webhook signature');
54+
* }
55+
* const notifications = decodeServerNotifications(body);
56+
* ```
57+
* @category Notifications
58+
*/
59+
export const verifyWebhookSignature = (
60+
body: Uint8Array | ArrayBuffer,
61+
signature: string,
62+
secret: string,
63+
): boolean => {
64+
const expected = createHmac('sha256', secret)
65+
.update(body instanceof Uint8Array ? body : new Uint8Array(body))
66+
.digest('hex');
67+
const provided = Buffer.from(signature.trim().replace(/^sha256=/, ''), 'utf8');
68+
const wanted = Buffer.from(expected, 'utf8');
69+
return provided.length === wanted.length && timingSafeEqual(provided, wanted);
70+
};

packages/js-server-sdk/tests/webhook.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { createHmac } from 'node:crypto';
2+
13
import { describe, it, expect } from 'vitest';
24
import { ServerMessage, ServerMessage_PeerType, TrackType } from '@fishjam-cloud/fishjam-proto';
3-
import { decodeServerNotifications } from '../src/webhook';
5+
import { decodeServerNotifications, verifyWebhookSignature } from '../src/webhook';
46

57
const encode = (message: Parameters<typeof ServerMessage.encode>[0]): Uint8Array =>
68
ServerMessage.encode(message).finish();
@@ -94,3 +96,33 @@ describe('decodeServerNotifications', () => {
9496
expect(decodeServerNotifications(arrayBuffer)).toHaveLength(1); // ArrayBuffer
9597
});
9698
});
99+
100+
describe('verifyWebhookSignature', () => {
101+
const secret = 'test-secret';
102+
const body = encode(peerConnected);
103+
// Mirrors the server: Base.encode16(:crypto.mac(:hmac, :sha256, secret, body), case: :lower)
104+
const validHex = createHmac('sha256', secret).update(body).digest('hex');
105+
106+
it('accepts the sha256=<hex> header format sent by the server', () => {
107+
expect(verifyWebhookSignature(body, `sha256=${validHex}`, secret)).toBe(true);
108+
});
109+
110+
it('accepts a bare hex signature and an ArrayBuffer body', () => {
111+
const arrayBuffer = new Uint8Array(body).buffer as ArrayBuffer;
112+
expect(verifyWebhookSignature(arrayBuffer, validHex, secret)).toBe(true);
113+
});
114+
115+
it('rejects a signature computed with a different secret', () => {
116+
const other = createHmac('sha256', 'other-secret').update(body).digest('hex');
117+
expect(verifyWebhookSignature(body, `sha256=${other}`, secret)).toBe(false);
118+
});
119+
120+
it('rejects a tampered body', () => {
121+
expect(verifyWebhookSignature(encode(trackAdded), `sha256=${validHex}`, secret)).toBe(false);
122+
});
123+
124+
it('rejects malformed signatures without throwing', () => {
125+
expect(verifyWebhookSignature(body, '', secret)).toBe(false);
126+
expect(verifyWebhookSignature(body, 'sha256=deadbeef', secret)).toBe(false);
127+
});
128+
});

0 commit comments

Comments
 (0)