diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 6a656e9ed..ccbd3eff3 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -19,7 +19,7 @@ export const TRACK_LABELS: Record = { export const APP_VERSION = "0.42.2"; export const CLI_VERSION = "1.4.1"; export const SDK_VERSION = "1.17.0"; -export const PYTHON_SDK_VERSION = "0.1.1"; +export const PYTHON_SDK_VERSION = "0.1.2"; export const MCP_VERSION = "1.13.0"; export const CHANGELOG: ChangelogEntry[] = [ @@ -1904,6 +1904,15 @@ export const CHANGELOG: ChangelogEntry[] = [ }, // ─── Python SDK ───────────────────────────────────────────────────── + { + version: "0.1.2", + date: "2026-10-23", + title: "Empty Signing Keys Are Refused", + track: "python", + items: [ + "A Standard Webhooks, Clerk, Resend or forwarding secret that decodes to no key at all (`whsec_` alone, or whitespace) now raises `ValueError` in `verify_signature()`, the forwarding checks and request building. Before, such a secret accepted any signature made with an empty key, which anyone can compute", + ], + }, { version: "0.1.1", date: "2026-10-23", diff --git a/packages/sdk-python/pyproject.toml b/packages/sdk-python/pyproject.toml index 8e338f3c3..7d11294bd 100644 --- a/packages/sdk-python/pyproject.toml +++ b/packages/sdk-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "webhooks-cc" -version = "0.1.1" +version = "0.1.2" description = "Python SDK for webhooks.cc: capture, wait for, sign and verify webhooks and test emails" readme = "README.md" license = "MIT" diff --git a/packages/sdk-python/src/webhooks_cc/__init__.py b/packages/sdk-python/src/webhooks_cc/__init__.py index 58aad950f..d3e5043f9 100644 --- a/packages/sdk-python/src/webhooks_cc/__init__.py +++ b/packages/sdk-python/src/webhooks_cc/__init__.py @@ -16,7 +16,7 @@ Every signature check for each provider is in :mod:`webhooks_cc.verify`. """ -__version__ = "0.1.1" +__version__ = "0.1.2" from .client import Emails, Endpoints, Forwarding, Requests, RetryOptions, WebhooksCC from .durations import Duration, parse_duration diff --git a/packages/sdk-python/src/webhooks_cc/_crypto.py b/packages/sdk-python/src/webhooks_cc/_crypto.py index 16787a045..d3766be3b 100644 --- a/packages/sdk-python/src/webhooks_cc/_crypto.py +++ b/packages/sdk-python/src/webhooks_cc/_crypto.py @@ -82,12 +82,19 @@ def decode_standard_webhook_secret(secret: str) -> bytes: A leading ``whsec_`` is stripped and the rest decoded as base64. When that fails, the key is the UTF-8 bytes of the whole secret, prefix included. + + Raises ``ValueError`` when that leaves no key at all (``"whsec_"``, or only + whitespace): an HMAC keyed with nothing is one anyone can compute, and + WebCrypto refuses it in the TypeScript SDK too. """ raw = secret[6:] if secret.startswith("whsec_") else secret try: - return from_base64(raw) + key = from_base64(raw) except ValueError: - return secret.encode("utf-8") + key = secret.encode("utf-8") + if not key: + raise ValueError("The secret decodes to an empty key") + return key _HUBSPOT_ESCAPES = { diff --git a/packages/sdk-python/src/webhooks_cc/verify.py b/packages/sdk-python/src/webhooks_cc/verify.py index d8b4decfa..790f1abcf 100644 --- a/packages/sdk-python/src/webhooks_cc/verify.py +++ b/packages/sdk-python/src/webhooks_cc/verify.py @@ -976,13 +976,14 @@ def verify_standard_webhook_signature(body: BodyInput, headers: HeadersInput, se The key is the ``whsec_`` secret decoded from base64, or its UTF-8 bytes when it is not base64. """ _require_secret(secret, "verify_standard_webhook_signature") + key = decode_standard_webhook_secret(secret) message_id = _get_header(headers, "webhook-id") timestamp = _get_header(headers, "webhook-timestamp") signature_header = _get_header(headers, "webhook-signature") if not message_id or not timestamp or not signature_header: return False payload = _encode_text(f"{message_id}.{timestamp}.") + _body_bytes(body) - expected = _hmac_base64("sha256", decode_standard_webhook_secret(secret), payload) + expected = _hmac_base64("sha256", key, payload) return any( _safe_equal(signature, expected) for signature in _parse_standard_signatures(signature_header) @@ -1044,6 +1045,7 @@ def _verify_signed_forward( function_name: str, ) -> Any: _require_secret(secret, function_name) + decode_standard_webhook_secret(secret) # an empty key is a ValueError, never a verdict message_id = _get_header(headers, "webhook-id") timestamp_header = _get_header(headers, "webhook-timestamp") signature_header = _get_header(headers, "webhook-signature") @@ -1139,6 +1141,7 @@ def verify_relayed_request( Raises ``WebhookVerificationError`` with ``code`` ``missing_headers`` or ``invalid_signature``. """ _require_secret(secret, "verify_relayed_request") + key = decode_standard_webhook_secret(secret) request_id = _get_header(headers, "webhooks-cc-request-id") received_at = _get_header(headers, "webhooks-cc-received-at") signature_header = _get_header(headers, "webhooks-cc-signature") @@ -1149,7 +1152,7 @@ def verify_relayed_request( "or webhooks-cc-signature header", ) payload = _encode_text(f"{request_id}.{received_at}.") + _body_bytes(body) - expected = _hmac_base64("sha256", decode_standard_webhook_secret(secret), payload) + expected = _hmac_base64("sha256", key, payload) if not any( _safe_equal(given, expected) for given in _parse_standard_signatures(signature_header) ): diff --git a/packages/sdk-python/tests/test_empty_key.py b/packages/sdk-python/tests/test_empty_key.py new file mode 100644 index 000000000..e081e7efd --- /dev/null +++ b/packages/sdk-python/tests/test_empty_key.py @@ -0,0 +1,92 @@ +"""A secret that decodes to an empty key is refused, never accepted as a verdict. + +An HMAC keyed with nothing is one anyone can compute; WebCrypto refuses such +a key in the TypeScript SDK, so the ports must too. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac + +import pytest + +from webhooks_cc import ( + build_template_request, + verify_forwarded_email, + verify_forwarded_request, + verify_relayed_request, + verify_signature, +) + +BODY = b'{"type":"email.received","timestamp":"2026-01-01T00:00:00.000Z","data":{}}' +EMPTY_SECRETS = ["whsec_", " ", "whsec_\n", "\t\n"] + + +def _forged(prefix: bytes) -> str: + return "v1," + base64.b64encode(hmac.new(b"", prefix + BODY, hashlib.sha256).digest()).decode() + + +HEADERS = { + "webhook-id": "msg_1", + "webhook-timestamp": "1767225600", + "webhook-signature": _forged(b"msg_1.1767225600."), + "svix-id": "msg_1", + "svix-timestamp": "1767225600", + "svix-signature": _forged(b"msg_1.1767225600."), + "webhooks-cc-request-id": "req_1", + "webhooks-cc-received-at": "2026-01-01T00:00:00.000Z", + "webhooks-cc-signature": _forged(b"req_1.2026-01-01T00:00:00.000Z."), +} + + +@pytest.mark.parametrize("secret", EMPTY_SECRETS) +@pytest.mark.parametrize("provider", ["standard-webhooks", "clerk", "resend"]) +def test_verify_signature_refuses_an_empty_key(provider: str, secret: str) -> None: + with pytest.raises(ValueError, match="empty key"): + verify_signature(provider, body=BODY, headers=HEADERS, secret=secret) + + +@pytest.mark.parametrize("secret", EMPTY_SECRETS) +def test_forwarding_checks_refuse_an_empty_key(secret: str) -> None: + with pytest.raises(ValueError, match="empty key"): + verify_forwarded_email(BODY, HEADERS, secret, now=1767225600000) + with pytest.raises(ValueError, match="empty key"): + verify_forwarded_request(BODY, HEADERS, secret, now=1767225600000) + with pytest.raises(ValueError, match="empty key"): + verify_relayed_request(BODY, HEADERS, secret) + + +@pytest.mark.parametrize("provider", ["standard-webhooks", "clerk", "resend"]) +def test_signing_refuses_an_empty_key(provider: str) -> None: + with pytest.raises(ValueError, match="empty key"): + build_template_request("https://example.com/hook", provider=provider, secret="whsec_") + + +def test_a_real_secret_still_verifies() -> None: + secret = "whsec_cm91bmR0cmlwLXNlY3JldA==" + built = build_template_request( + "https://example.com/hook", provider="standard-webhooks", secret=secret + ) + assert verify_signature( + "standard-webhooks", body=built.body, headers=built.headers, secret=secret + ) + + +STALE = {"webhook-id": "m", "webhook-timestamp": "1", "webhook-signature": "v1,x"} + + +@pytest.mark.parametrize("headers", [{}, STALE], ids=["no headers", "stale timestamp"]) +def test_an_empty_key_is_reported_before_the_headers_are_checked( + headers: dict[str, str], +) -> None: + # Otherwise missing_headers or timestamp_out_of_range would hide the misconfiguration. + with pytest.raises(ValueError, match="empty key"): + verify_forwarded_email(BODY, headers, "whsec_", now=1767225600000) + with pytest.raises(ValueError, match="empty key"): + verify_forwarded_request(BODY, headers, "whsec_", now=1767225600000) + with pytest.raises(ValueError, match="empty key"): + verify_relayed_request(BODY, headers, "whsec_") + with pytest.raises(ValueError, match="empty key"): + verify_signature("standard-webhooks", body=BODY, headers=headers, secret="whsec_") diff --git a/packages/sdk-python/uv.lock b/packages/sdk-python/uv.lock index b30a2f91b..31ff30467 100644 --- a/packages/sdk-python/uv.lock +++ b/packages/sdk-python/uv.lock @@ -744,7 +744,7 @@ wheels = [ [[package]] name = "webhooks-cc" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "httpx" },