Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/web/lib/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const TRACK_LABELS: Record<ChangelogTrack, string> = {
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[] = [
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-python/src/webhooks_cc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions packages/sdk-python/src/webhooks_cc/_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk-python/src/webhooks_cc/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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)
):
Expand Down
92 changes: 92 additions & 0 deletions packages/sdk-python/tests/test_empty_key.py
Original file line number Diff line number Diff line change
@@ -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_")
2 changes: 1 addition & 1 deletion packages/sdk-python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading