From 6e78da1ee17be4ceb7d2a6ac6f116a998d081a42 Mon Sep 17 00:00:00 2001 From: apinator-dev Date: Sat, 17 Oct 2026 16:37:01 +0200 Subject: [PATCH] feat: verify webhooks with the endpoint's webhook secret Deliveries are signed with each endpoint's own webhook secret (whsec_..., shown in the console), not the app's API secret. client.verifyWebhook passed the API secret, so it could never verify a real delivery. It now takes the webhook secret as its first argument; the standalone verify function is unchanged. Also: - With a max age set, timestamps more than 300s in the future are rejected in every SDK (previously node/go rejected any future timestamp, python/php accepted all). - Shared conformance fixture gains verify_cases (array headers, skew, expiry, wrong secret), run by every SDK. - Docs and examples use the webhook secret, a 5 minute max age, the event payload shape ({id, event, app_id, channel, user_id, time_ms}) and dedupe on id. - List/tuple header values use the first entry; a non-ASCII signature returns False instead of raising. --- README.md | 34 ++++++++++++++++----- apinator/client.py | 28 +++++++++++++---- apinator/webhook.py | 48 +++++++++++++++++++++-------- docs/api-reference.md | 45 ++++++++++++++++++++------- docs/quickstart.md | 21 ++++++++++--- examples/webhook_handler.py | 54 +++++++++++++++++++++----------- tests/test_client.py | 61 +++++++++++++++++++++++++++++-------- tests/test_conformance.py | 36 +++++++++++++++++++++- tests/test_webhook.py | 45 +++++++++++++++++++++++++++ 9 files changed, 302 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 11209d1..0a7a4e0 100644 --- a/README.md +++ b/README.md @@ -94,28 +94,48 @@ auth = client.authenticate_channel(socket_id, channel_name, channel_data) ## Webhook Verification +Each webhook endpoint you create in the console has its own signing secret +(`whsec_...`). Verify deliveries with that secret, not the app's API secret, and +pass the raw request body exactly as received. + ```python +import json +import os + from apinator import Apinator client = Apinator( app_id="your-app-id", key="your-app-key", - secret="your-webhook-secret", + secret="your-app-secret", cluster="eu", ) +WEBHOOK_SECRET = os.environ["APINATOR_WEBHOOK_SECRET"] # whsec_... -# In your webhook route handler: +# In your webhook route handler (Flask shown): headers = dict(request.headers) body = request.get_data(as_text=True) -if client.verify_webhook(headers, body, max_age=300): - # Webhook is valid — process the payload - payload = json.loads(body) -else: - # Invalid webhook +if not client.verify_webhook(WEBHOOK_SECRET, headers, body, max_age=300): abort(401) + +payload = json.loads(body) +# {"id": "", "event": "member_added", "app_id": "...", +# "channel": "presence-room", "user_id": "42", "time_ms": 1760000000000} +# user_id is only present on member_added / member_removed. + +# Deliveries are at-least-once: skip an id you have already processed. +if already_processed(payload["id"]): + return "", 200 ``` +Each delivery carries `X-Realtime-Signature` (`sha256=`), +`X-Realtime-Timestamp` (unix seconds), `X-Realtime-Event` and +`X-Realtime-Delivery-ID`. Events: `channel_occupied`, `channel_vacated`, +`member_added`, `member_removed`; the console's test button sends `webhook.test`. +With `max_age` set, timestamps older than `max_age` seconds or more than 300 +seconds in the future are rejected. + ## Channel Introspection ```python diff --git a/apinator/client.py b/apinator/client.py index 0aad58d..b86068b 100644 --- a/apinator/client.py +++ b/apinator/client.py @@ -8,12 +8,12 @@ import time import urllib.request import urllib.error -from typing import Any +from typing import Any, Mapping from urllib.parse import quote, urlencode from .crypto import sign_request from .auth import authenticate_channel as auth_channel -from .webhook import verify_webhook as verify_webhook_signature +from .webhook import HeaderValue, verify_webhook as verify_webhook_signature from .errors import ValidationError, AuthenticationError, ApiError @@ -154,22 +154,38 @@ def get_channel(self, channel_name: str) -> dict: def verify_webhook( self, - headers: dict[str, str], + webhook_secret: str, + headers: Mapping[str, HeaderValue], body: str, max_age: int | None = None ) -> bool: """ Verify a webhook signature. + Webhooks are signed with the webhook endpoint's own signing secret + (``whsec_...``, shown in the console per endpoint), not with the app's + API secret, so it must be passed explicitly. + Args: + webhook_secret: The webhook endpoint's signing secret (``whsec_...``) headers: HTTP headers from webhook request - body: Webhook body as string - max_age: Optional max age in seconds + body: Raw webhook body as string, exactly as received + max_age: Optional max age in seconds (300 recommended). When set, + timestamps older than ``max_age`` or more than 300 seconds in + the future are rejected. Returns: True if signature is valid + + Raises: + ValidationError: If ``webhook_secret`` is not a non-empty string """ - return verify_webhook_signature(self.secret, headers, body, max_age) + if not isinstance(webhook_secret, str) or not webhook_secret: + raise ValidationError( + "verify_webhook requires the webhook signing secret (whsec_...) " + "as its first argument" + ) + return verify_webhook_signature(webhook_secret, headers, body, max_age) def _request( self, diff --git a/apinator/webhook.py b/apinator/webhook.py index bcfbcc9..8f3f32e 100644 --- a/apinator/webhook.py +++ b/apinator/webhook.py @@ -6,33 +6,55 @@ import hmac import time +from typing import Any, Mapping, Union from .crypto import sign_webhook_payload +#: How far in the future (seconds) a webhook timestamp may be before it is +#: rejected, when ``max_age`` is given. Allows for clock skew. +MAX_FUTURE_SKEW_SECONDS = 300 + +HeaderValue = Union[str, list[str], tuple[str, ...], None] + + +def _header_value(value: Any) -> str | None: + """Return a header value as a string; list/tuple values use the first string entry.""" + if isinstance(value, (list, tuple)): + value = next((v for v in value if isinstance(v, str)), None) + if isinstance(value, bytes): + value = value.decode("latin-1") + return value if isinstance(value, str) else None + def verify_webhook( secret: str, - headers: dict[str, str], + headers: Mapping[str, HeaderValue], body: str, max_age: int | None = None ) -> bool: """ Verify a webhook signature. + Apinator signs each delivery with the webhook endpoint's own signing secret + (``whsec_...``, shown in the console per endpoint), not the app's API secret: + ``X-Realtime-Signature: sha256=hex(hmac_sha256(secret, "{timestamp}.{raw_body}"))``. + Args: - secret: The webhook secret - headers: HTTP headers (case-insensitive lookup) - body: The webhook body as string - max_age: Optional max age in seconds to check timestamp freshness + secret: The webhook endpoint's signing secret (``whsec_...``) + headers: HTTP headers (case-insensitive lookup; list values use the first entry) + body: The raw webhook body as string, exactly as received + max_age: Optional max age in seconds (300 recommended). When set, + timestamps older than ``max_age`` or more than 300 seconds in the + future are rejected. Returns: True if signature is valid and timestamp is fresh (if max_age provided) """ # Case-insensitive header lookup - headers_lower = {k.lower(): v for k, v in headers.items()} + headers_lower = {str(k).lower(): v for k, v in headers.items()} - signature_header = headers_lower.get('x-realtime-signature') - timestamp_header = headers_lower.get('x-realtime-timestamp') + signature_header = _header_value(headers_lower.get('x-realtime-signature')) + timestamp_header = _header_value(headers_lower.get('x-realtime-timestamp')) if not signature_header or not timestamp_header: return False @@ -47,14 +69,16 @@ def verify_webhook( if max_age is not None: try: timestamp = int(timestamp_header) - current_time = int(time.time()) - if current_time - timestamp > max_age: - return False except ValueError: return False + age = int(time.time()) - timestamp + if age > max_age or -age > MAX_FUTURE_SKEW_SECONDS: + return False # Compute expected signature expected_signature = sign_webhook_payload(secret, timestamp_header, body) - # Timing-safe comparison + # Timing-safe comparison (compare_digest rejects non-ASCII str input) + if not provided_signature.isascii(): + return False return hmac.compare_digest(expected_signature, provided_signature) diff --git a/docs/api-reference.md b/docs/api-reference.md index d0cb3a3..11ace29 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -135,24 +135,45 @@ info = client.get_channel("presence-chat") --- -### `verify_webhook(headers, body, max_age=None)` +### `verify_webhook(webhook_secret, headers, body, max_age=None)` -Verify a webhook signature. +Verify a webhook signature. Webhooks are signed with the webhook endpoint's own +signing secret (`whsec_...`, shown in the console per endpoint), not the app's +API secret. ```python -is_valid = client.verify_webhook(headers, body, max_age=300) +is_valid = client.verify_webhook( + os.environ["APINATOR_WEBHOOK_SECRET"], headers, body, max_age=300 +) ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| -| `headers` | `dict[str, str]` | HTTP headers from the webhook request | -| `body` | `str` | Webhook body as string | -| `max_age` | `int \| None` | Optional max age in seconds for timestamp freshness check | +| `webhook_secret` | `str` | The webhook endpoint's signing secret (`whsec_...`) | +| `headers` | `Mapping[str, str \| list[str]]` | HTTP headers (case-insensitive; list values use the first entry) | +| `body` | `str` | Raw webhook body, exactly as received | +| `max_age` | `int \| None` | Max age in seconds (300 recommended). When set, timestamps older than `max_age` or more than 300 seconds in the future are rejected | **Returns:** `bool` — `True` if the signature is valid +**Raises:** +- `ValidationError` — if `webhook_secret` is empty or not a string + +The signature header is +`X-Realtime-Signature: sha256=hex(hmac_sha256(webhook_secret, "{timestamp}.{raw_body}"))`, +with the timestamp from `X-Realtime-Timestamp`. Deliveries also carry +`X-Realtime-Event` and `X-Realtime-Delivery-ID`. Payload: + +```json +{"id": "", "event": "member_added", "app_id": "...", "channel": "presence-room", "user_id": "42", "time_ms": 1760000000000} +``` + +`event` is one of `channel_occupied`, `channel_vacated`, `member_added`, +`member_removed` (`user_id` only on the member events); the console's test sends +`webhook.test`. Deliveries are at-least-once, so dedupe on `id`. + --- ## Standalone Functions @@ -189,10 +210,12 @@ auth = authenticate_channel( ### `verify_webhook(secret, headers, body, max_age=None)` ```python +import os + from apinator import verify_webhook is_valid = verify_webhook( - secret="your-webhook-secret", + secret=os.environ["APINATOR_WEBHOOK_SECRET"], # whsec_... headers=headers, body=body, max_age=300, @@ -203,10 +226,10 @@ is_valid = verify_webhook( | Parameter | Type | Description | |-----------|------|-------------| -| `secret` | `str` | Webhook secret | -| `headers` | `dict[str, str]` | HTTP headers (case-insensitive lookup) | -| `body` | `str` | Webhook body as string | -| `max_age` | `int \| None` | Optional max age in seconds | +| `secret` | `str` | The webhook endpoint's signing secret (`whsec_...`), not the API secret | +| `headers` | `Mapping[str, str \| list[str]]` | HTTP headers (case-insensitive; list values use the first entry) | +| `body` | `str` | Raw webhook body, exactly as received | +| `max_age` | `int \| None` | Max age in seconds (300 recommended); also rejects timestamps more than 300s in the future | **Returns:** `bool` — `True` if the signature is valid and timestamp is fresh diff --git a/docs/quickstart.md b/docs/quickstart.md index 22fc978..c9e041f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -96,8 +96,12 @@ def auth_channel(): Verify incoming webhooks to ensure they are authentic: +Use the webhook endpoint's signing secret (`whsec_...`, shown in the console per +endpoint), not the app's API secret: + ```python import json +import os from flask import Flask, request, abort from apinator import Apinator @@ -105,21 +109,30 @@ app = Flask(__name__) client = Apinator( app_id="your-app-id", key="your-app-key", - secret="your-webhook-secret", + secret="your-app-secret", cluster="eu", ) +WEBHOOK_SECRET = os.environ["APINATOR_WEBHOOK_SECRET"] +processed_ids: set[str] = set() # use a persistent store in production @app.route("/webhooks/apinator", methods=["POST"]) def handle_webhook(): headers = dict(request.headers) body = request.get_data(as_text=True) - if not client.verify_webhook(headers, body, max_age=300): + if not client.verify_webhook(WEBHOOK_SECRET, headers, body, max_age=300): abort(401) payload = json.loads(body) - # Process the webhook payload - print(f"Received event: {payload}") + # {"id": "...", "event": "member_added", "app_id": "...", "channel": "presence-room", + # "user_id": "42", "time_ms": 1760000000000} + + # Deliveries are at-least-once: dedupe on id. + if payload["id"] in processed_ids: + return "", 200 + processed_ids.add(payload["id"]) + + print(f"Received {payload['event']} on {payload.get('channel')}") return "", 200 ``` diff --git a/examples/webhook_handler.py b/examples/webhook_handler.py index e659267..182e675 100644 --- a/examples/webhook_handler.py +++ b/examples/webhook_handler.py @@ -3,47 +3,67 @@ Usage: pip install flask + export APINATOR_WEBHOOK_SECRET=whsec_... # from the webhook endpoint in the console python webhook_handler.py """ import json +import os + from flask import Flask, request, abort from apinator import Apinator app = Flask(__name__) client = Apinator( - app_id="your-app-id", - key="your-app-key", - secret="your-webhook-secret", + app_id=os.environ.get("APINATOR_APP_ID", "your-app-id"), + key=os.environ.get("APINATOR_KEY", "your-app-key"), + secret=os.environ.get("APINATOR_SECRET", "your-app-secret"), cluster="eu", ) +# Each webhook endpoint has its own signing secret (whsec_...). It is not the +# app's API secret. +WEBHOOK_SECRET = os.environ["APINATOR_WEBHOOK_SECRET"] + +# Deliveries are at-least-once, so remember which event ids were handled. +# Use a persistent store (database, Redis) in production. +processed_ids: set[str] = set() + @app.route("/webhooks/apinator", methods=["POST"]) def handle_webhook(): headers = dict(request.headers) body = request.get_data(as_text=True) - # Verify the webhook signature (max_age=300 means reject if older than 5 min) - if not client.verify_webhook(headers, body, max_age=300): + # Reject bad signatures, and timestamps older than 5 minutes or more than + # 5 minutes in the future. + if not client.verify_webhook(WEBHOOK_SECRET, headers, body, max_age=300): abort(401) + # {"id": "", "event": "member_added", "app_id": "...", + # "channel": "presence-room", "user_id": "42", "time_ms": 1760000000000} payload = json.loads(body) - # Handle different event types - event_type = payload.get("type") - - if event_type == "channel_occupied": - print(f"Channel occupied: {payload.get('channel')}") - elif event_type == "channel_vacated": - print(f"Channel vacated: {payload.get('channel')}") - elif event_type == "member_added": - print(f"Member added to {payload.get('channel')}: {payload.get('user_id')}") - elif event_type == "member_removed": - print(f"Member removed from {payload.get('channel')}: {payload.get('user_id')}") + if payload["id"] in processed_ids: + return "", 200 # duplicate delivery + processed_ids.add(payload["id"]) + + event = payload.get("event") + channel = payload.get("channel") + + if event == "channel_occupied": + print(f"Channel occupied: {channel}") + elif event == "channel_vacated": + print(f"Channel vacated: {channel}") + elif event == "member_added": + print(f"Member added to {channel}: {payload.get('user_id')}") + elif event == "member_removed": + print(f"Member removed from {channel}: {payload.get('user_id')}") + elif event == "webhook.test": + print("Test webhook received from the console") else: - print(f"Received event: {event_type}") + print(f"Received event: {event}") return "", 200 diff --git a/tests/test_client.py b/tests/test_client.py index 61a0493..95365c0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -314,22 +314,36 @@ def test_authenticate_channel_with_data(self): class TestVerifyWebhook: """Tests for verify_webhook method""" + WEBHOOK_SECRET = "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + BODY = ( + '{"id":"cbb368a1-bc93-42bc-b939-51c7352cbd03","event":"member_added",' + '"app_id":"test-app-123","channel":"presence-room","user_id":"42",' + '"time_ms":1760000000000}' + ) + + def _headers(self, secret, timestamp): + from apinator.crypto import sign_webhook_payload + signature = sign_webhook_payload(secret, timestamp, self.BODY) + return { + "X-Realtime-Signature": f"sha256={signature}", + "X-Realtime-Timestamp": timestamp, + } + def test_verify_webhook(self): - """verify_webhook should verify signature""" + """verify_webhook should verify a signature made with the webhook secret""" + import time client = Apinator(TEST_APP_ID, TEST_KEY, TEST_SECRET, cluster=TEST_CLUSTER) + headers = self._headers(self.WEBHOOK_SECRET, str(int(time.time()))) - # Create valid signature - from apinator.crypto import sign_webhook_payload - timestamp = "1700000000" - body = '{"event":"test"}' - signature = sign_webhook_payload(TEST_SECRET, timestamp, body) + assert client.verify_webhook(self.WEBHOOK_SECRET, headers, self.BODY, max_age=300) is True - headers = { - "X-Realtime-Signature": signature, - "X-Realtime-Timestamp": timestamp - } + def test_verify_webhook_does_not_use_api_secret(self): + """A signature made with the app's API secret must not verify""" + import time + client = Apinator(TEST_APP_ID, TEST_KEY, TEST_SECRET, cluster=TEST_CLUSTER) + headers = self._headers(TEST_SECRET, str(int(time.time()))) - assert client.verify_webhook(headers, body) is True + assert client.verify_webhook(self.WEBHOOK_SECRET, headers, self.BODY) is False def test_verify_webhook_invalid(self): """verify_webhook should reject invalid signature""" @@ -340,9 +354,32 @@ def test_verify_webhook_invalid(self): "X-Realtime-Timestamp": "1700000000" } - assert client.verify_webhook(headers, '{"event":"test"}') is False + assert client.verify_webhook(self.WEBHOOK_SECRET, headers, self.BODY) is False + + def test_verify_webhook_max_age(self): + """max_age rejects old timestamps and timestamps >300s in the future""" + import time + client = Apinator(TEST_APP_ID, TEST_KEY, TEST_SECRET, cluster=TEST_CLUSTER) + now = int(time.time()) + + old = self._headers(self.WEBHOOK_SECRET, str(now - 400)) + assert client.verify_webhook(self.WEBHOOK_SECRET, old, self.BODY) is True + assert client.verify_webhook(self.WEBHOOK_SECRET, old, self.BODY, max_age=300) is False + + future = self._headers(self.WEBHOOK_SECRET, str(now + 600)) + assert client.verify_webhook(self.WEBHOOK_SECRET, future, self.BODY, max_age=300) is False + def test_verify_webhook_requires_secret(self): + """verify_webhook raises when the webhook secret is missing or misplaced""" + import time + client = Apinator(TEST_APP_ID, TEST_KEY, TEST_SECRET, cluster=TEST_CLUSTER) + headers = self._headers(self.WEBHOOK_SECRET, str(int(time.time()))) + with pytest.raises(ValidationError): + client.verify_webhook("", headers, self.BODY) + with pytest.raises(ValidationError): + # Old call shape: headers first + client.verify_webhook(headers, self.BODY, 300) # type: ignore[arg-type] class TestRequestSigning: """Tests for request signing""" diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 2c781b0..9668e33 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -1,4 +1,5 @@ import json +import time from pathlib import Path from io import BytesIO from unittest.mock import patch @@ -9,6 +10,7 @@ from apinator import Apinator from apinator.crypto import sign_request, sign_channel, sign_webhook_payload from apinator.auth import authenticate_channel +from apinator.webhook import verify_webhook from apinator.errors import AuthenticationError @@ -22,6 +24,15 @@ def _load_fixture(name: str): return json.loads(path.read_text()) +def _load_webhook_fixture(): + # The webhook fixture is shared from the Node SDK (sdks/node/test/fixtures), + # which also carries the verification cases. + path = ROOT / "sdks" / "node" / "test" / "fixtures" / "webhook-signature.v1.json" + if not path.exists(): + pytest.skip("Shared webhook fixture not available (only runs inside monorepo)") + return json.loads(path.read_text()) + + def test_hmac_fixture_vectors(): fixture = _load_fixture("hmac-request.v1.json") for case in fixture["cases"]: @@ -82,7 +93,7 @@ def test_channel_fixture_vectors(): def test_webhook_fixture_vectors(): - fixture = _load_fixture("webhook-signature.v1.json") + fixture = _load_webhook_fixture() for case in fixture["cases"]: assert ( sign_webhook_payload(case["secret"], case["timestamp"], case["body"]) @@ -90,6 +101,29 @@ def test_webhook_fixture_vectors(): ) +def test_webhook_verify_fixture_cases(): + fixture = _load_webhook_fixture() + cases = fixture["verify_cases"] + assert cases + + for case in cases: + timestamp = str(int(time.time()) + case["timestamp_offset"]) + signature = sign_webhook_payload( + case.get("signing_secret") or case["secret"], timestamp, case["body"] + ) + + def fill(value: str) -> str: + return value.replace("{timestamp}", timestamp).replace("{signature}", signature) + + headers = { + name: [fill(v) for v in value] if isinstance(value, list) else fill(value) + for name, value in case["headers"].items() + } + + valid = verify_webhook(case["secret"], headers, case["body"], case["max_age"]) + assert valid is case["expected_valid"], case["name"] + + def test_client_parses_rfc7807_errors(): client = Apinator("app", "key", "secret", cluster="eu") body = BytesIO( diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 4850a60..620ffe3 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -180,3 +180,48 @@ def test_verify_timing_safe_comparison(self): bad_signature = signature[:-1] + ("0" if signature[-1] != "0" else "1") headers["X-Realtime-Signature"] = bad_signature assert verify_webhook(TEST_SECRET, headers, body) is False + + def test_verify_future_timestamp_within_skew(self): + """Timestamps up to 300s in the future are accepted when max_age is set""" + timestamp = str(int(time.time()) + 60) + body = '{"event":"channel_occupied","channel":"test"}' + signature = sign_webhook_payload(TEST_SECRET, timestamp, body) + headers = {"X-Realtime-Signature": signature, "X-Realtime-Timestamp": timestamp} + + assert verify_webhook(TEST_SECRET, headers, body, max_age=300) is True + + def test_verify_future_timestamp_rejected(self): + """Timestamps more than 300s in the future are rejected when max_age is set""" + timestamp = str(int(time.time()) + 600) + body = '{"event":"channel_occupied","channel":"test"}' + signature = sign_webhook_payload(TEST_SECRET, timestamp, body) + headers = {"X-Realtime-Signature": signature, "X-Realtime-Timestamp": timestamp} + + assert verify_webhook(TEST_SECRET, headers, body, max_age=300) is False + assert verify_webhook(TEST_SECRET, headers, body, max_age=3600) is False + # No freshness check without max_age + assert verify_webhook(TEST_SECRET, headers, body) is True + + def test_verify_list_header_values(self): + """List-valued headers use their first entry""" + timestamp = str(int(time.time())) + body = '{"event":"channel_occupied","channel":"test"}' + signature = sign_webhook_payload(TEST_SECRET, timestamp, body) + headers = { + "x-realtime-signature": [f"sha256={signature}"], + "x-realtime-timestamp": [timestamp], + } + + assert verify_webhook(TEST_SECRET, headers, body, max_age=300) is True + + def test_verify_empty_list_header_rejected(self): + """An empty list header is treated as missing""" + headers = {"X-Realtime-Signature": [], "X-Realtime-Timestamp": ["1700000000"]} + + assert verify_webhook(TEST_SECRET, headers, "{}") is False + + def test_verify_non_ascii_signature_rejected(self): + """A non-ASCII signature header returns False instead of raising""" + headers = {"X-Realtime-Signature": "sha256=\u00e9", "X-Realtime-Timestamp": "1700000000"} + + assert verify_webhook(TEST_SECRET, headers, "{}") is False