From b1c4f7b59edaaca6aa05013335414117291c2d3c Mon Sep 17 00:00:00 2001 From: Sauer Date: Fri, 23 Oct 2026 23:10:37 +0000 Subject: [PATCH 1/2] fix(sdk-python): refuse secrets that decode to an empty key (0.1.2) decode_standard_webhook_secret turned "whsec_" or a whitespace-only secret into a zero-length HMAC key, and an HMAC keyed with nothing is one anyone can compute: verify_signature for standard-webhooks, clerk and resend, and the forwarding checks, accepted such forged signatures. It now raises ValueError, before any verdict, and request building refuses the key too, as WebCrypto does in the TypeScript SDK. A secret read from an empty variable or file is the realistic way to hit it. Found by an independent review of the Go port (#539), which has the same fix. Co-Authored-By: Claude Opus 5.5 --- apps/web/lib/changelog.ts | 11 ++- packages/sdk-python/pyproject.toml | 2 +- .../sdk-python/src/webhooks_cc/__init__.py | 2 +- .../sdk-python/src/webhooks_cc/_crypto.py | 11 ++- packages/sdk-python/src/webhooks_cc/verify.py | 7 +- packages/sdk-python/tests/test_empty_key.py | 74 +++++++++++++++++++ packages/sdk-python/uv.lock | 2 +- 7 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 packages/sdk-python/tests/test_empty_key.py diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 6a656e9e..ccbd3eff 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 8e338f3c..7d11294b 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 58aad950..d3e5043f 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 16787a04..d3766be3 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 d8b4decf..790f1abc 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 00000000..2e9dfe20 --- /dev/null +++ b/packages/sdk-python/tests/test_empty_key.py @@ -0,0 +1,74 @@ +"""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 + ) diff --git a/packages/sdk-python/uv.lock b/packages/sdk-python/uv.lock index b30a2f91..31ff3046 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" }, From 6129da1a7d118f3d0f1ef15a70ea26f6b7d58fb2 Mon Sep 17 00:00:00 2001 From: Sauer Date: Fri, 23 Oct 2026 23:18:01 +0000 Subject: [PATCH 2/2] test(sdk-python): an empty key is reported before the headers are checked From the review of #540: the tests always sent complete, fresh headers, so removing the early key check in the forwarding functions went unnoticed; missing headers or a stale timestamp would then have hidden the misconfigured secret behind missing_headers or timestamp_out_of_range. The new cases fail without the early check. Co-Authored-By: Claude Opus 5.5 --- packages/sdk-python/tests/test_empty_key.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/sdk-python/tests/test_empty_key.py b/packages/sdk-python/tests/test_empty_key.py index 2e9dfe20..e081e7ef 100644 --- a/packages/sdk-python/tests/test_empty_key.py +++ b/packages/sdk-python/tests/test_empty_key.py @@ -72,3 +72,21 @@ def test_a_real_secret_still_verifies() -> None: 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_")