From 82f9897861446a232ec1a0f36c80092a50a7095c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ro=C5=BCnawski?= Date: Tue, 14 Jul 2026 18:39:16 +0200 Subject: [PATCH] Add verify_webhook_signature helper for webhook authenticity --- fishjam/__init__.py | 7 ++++++- fishjam/_webhook_notifier.py | 21 +++++++++++++++++++++ tests/test_webhook_notifier.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/fishjam/__init__.py b/fishjam/__init__.py index f55fdc4..054c80c 100644 --- a/fishjam/__init__.py +++ b/fishjam/__init__.py @@ -12,7 +12,11 @@ from fishjam._openapi_client.models import PeerMetadata # API -from fishjam._webhook_notifier import decode_server_notifications, receive_binary +from fishjam._webhook_notifier import ( + decode_server_notifications, + receive_binary, + verify_webhook_signature, +) from fishjam._ws_notifier import FishjamNotifier from fishjam.api._fishjam_client import ( AgentOptions, @@ -34,6 +38,7 @@ "FishjamNotifier", "decode_server_notifications", "receive_binary", + "verify_webhook_signature", "PeerMetadata", "PeerOptions", "PeerOptionsVapi", diff --git a/fishjam/_webhook_notifier.py b/fishjam/_webhook_notifier.py index 9b54e8c..3b75edb 100644 --- a/fishjam/_webhook_notifier.py +++ b/fishjam/_webhook_notifier.py @@ -1,5 +1,6 @@ """Module for decoding received webhook notifications from Fishjam.""" +import hmac import warnings from typing import List, Union @@ -69,6 +70,26 @@ def decode_server_notifications(binary: bytes) -> List[AllowedNotification]: return [] +def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool: + """Verify the `x-fishjam-signature-256` header of a raw webhook body. + + Accepts the `sha256=` format sent by Fishjam (the prefix is + optional) and compares in constant time. Call this with the raw request + body before passing it to `decode_server_notifications`. + + Args: + body: The raw binary body of the webhook request. + signature: The value of the `x-fishjam-signature-256` header. + secret: The webhook secret configured in Fishjam. + + Returns: + bool: True when the signature matches the body, False otherwise. + """ + expected = hmac.new(secret.encode(), body, "sha256").hexdigest() + provided = signature.strip().removeprefix("sha256=") + return hmac.compare_digest(provided, expected) + + def receive_binary( binary: bytes, ) -> Union[AllowedNotification, List[AllowedNotification], None]: diff --git a/tests/test_webhook_notifier.py b/tests/test_webhook_notifier.py index 5a35113..9cebd4a 100644 --- a/tests/test_webhook_notifier.py +++ b/tests/test_webhook_notifier.py @@ -1,6 +1,12 @@ +import hmac + import pytest -from fishjam import decode_server_notifications, receive_binary +from fishjam import ( + decode_server_notifications, + receive_binary, + verify_webhook_signature, +) from fishjam.events import ( ServerMessagePeerConnected, ServerMessageRoomCreated, @@ -171,3 +177,28 @@ def test_decode_empty_batch_returns_empty_list(): ) assert decode_server_notifications(binary) == [] + + +BODY = bytes(ServerMessage(room_created=ServerMessageRoomCreated(room_id="r1"))) +SECRET = "webhook-secret" +SIGNATURE = hmac.new(SECRET.encode(), BODY, "sha256").hexdigest() + + +def test_verify_valid_signature_with_prefix(): + assert verify_webhook_signature(BODY, f"sha256={SIGNATURE}", SECRET) + + +def test_verify_valid_signature_without_prefix(): + assert verify_webhook_signature(BODY, SIGNATURE, SECRET) + + +def test_verify_wrong_secret_fails(): + assert not verify_webhook_signature(BODY, f"sha256={SIGNATURE}", "other-secret") + + +def test_verify_tampered_body_fails(): + assert not verify_webhook_signature(BODY + b"x", f"sha256={SIGNATURE}", SECRET) + + +def test_verify_garbage_signature_fails(): + assert not verify_webhook_signature(BODY, "sha256=nothex", SECRET)