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
30 changes: 26 additions & 4 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,31 @@
# Stainless machinery (release-please, publish-pypi.yml, release-doctor.yml, bin/) was
# removed with the cutover.
#
# src/whop_sdk/lib holds verify_user_token, the one hand-written helper in this SDK. It
# depends only on the standard library and pyjwt, so it survives the generated client
# being replaced. The [user-tokens] extra that declares pyjwt does not survive — see the
# cutover notes in the monorepo's sdks/fern/README.md.
# src/whop_sdk/lib holds the hand-written helpers in this SDK. Each depends only on the
# standard library and one third-party package, never on the generated client, so each
# survives the generated client being replaced:
#
# - verify_user_token — the x-whop-user-token verifier, on pyjwt.
# - verify_webhook — the Standard Webhooks signature verifier, on standardwebhooks. It
# restores the verification half of `client.webhooks.unwrap`, which the Stainless SDK
# shipped through 0.0.41 and Fern cannot generate: Fern generates from OpenAPI paths
# and `unwrap` was never a path.
#
# Reaching a helper needs nothing else — src/whop_sdk/lib is a real package and
# `from whop_sdk.lib.verify_webhook import unwrap` resolves without a generated re-export,
# which is why there is no Python equivalent of the Ruby SDK's requirePaths entry.
# Declaring what a helper imports does: pyproject.toml is generated, so the declaration
# comes from `extra_dependencies` in the python config in whop-monorepo's
# sdks/fern/generators.yml, not from here. standardwebhooks is declared there.
# pyjwt is not — the [user-tokens] extra did not survive the cutover, so importing
# verify_user_token from a clean install still raises ImportError.
#
# tests/custom holds the generated placeholder test plus the helpers' tests. Fern owns
# tests/, so without this entry a regeneration would prune them and a helper would go
# untested rather than fail loudly. tests/custom/test_verify_webhook.py also asserts the
# standardwebhooks declaration survived, which is where Ruby's ci.yml guard would sit if
# this repo's ci.yml were hand-written; it is not, and forking it for a guard is a worse
# trade than putting the guard in a test.
.github/workflows/publish-main.yml
src/whop_sdk/lib
tests/custom
14 changes: 13 additions & 1 deletion poetry.lock

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

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ httpx = ">=0.21.2"
httpx-aiohttp = { version = "^0.1.8", optional = true, python = ">=3.10"}
pydantic = ">= 1.9.2"
pydantic-core = ">=2.18.2,<3.0.0"
standardwebhooks = ">=1.0.1,<2"
typing_extensions = ">= 4.0.0"

[tool.poetry.group.dev.dependencies]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
httpx>=0.21.2
pydantic>= 1.9.2
pydantic-core>=2.18.2,<3.0.0
standardwebhooks>=1.0.1,<2
typing_extensions>= 4.0.0
92 changes: 92 additions & 0 deletions src/whop_sdk/lib/verify_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Verify the Standard Webhooks signature Whop sends on every webhook delivery.

This is the verification half of the ``client.webhooks.unwrap`` the Stainless-generated
SDK shipped through 0.0.41. Fern generates from OpenAPI paths and ``unwrap`` was never a
path, so the generated client has no equivalent. It lives here, beside
``verify_user_token``, rather than on the client so that nothing generated has to be
patched: it depends only on the standard library and ``standardwebhooks``, never on
generated client code, so it survives the client being replaced.

from whop_sdk.lib.verify_webhook import unwrap

event = unwrap(request.body, headers=request.headers, key=WHOP_WEBHOOK_SECRET)

What it does NOT do, and the Stainless version did: coerce the parsed body into one of
42 typed event models. Fern generates no webhook event models — ``whop_sdk.WebhookEvent``
is the enum of event *names* a webhook subscribes to, not a payload type — so there is
nothing to coerce into. The parsed body is returned as a plain dict.
"""

from __future__ import annotations

import base64
import json
from typing import Any, Dict, Mapping, Union

from standardwebhooks import Webhook, WebhookVerificationError

__all__ = ["unwrap", "WebhookVerificationError"]

MISSING_KEY_MESSAGE = "Cannot verify a webhook without a key. Pass the endpoint's signing secret as `key`."


def _hmac_key(key: Union[str, bytes]) -> str:
"""Base64-encode the secret so ``Webhook`` derives the key Whop actually signs with.

Whop's backend HMACs with the *literal bytes* of the secret it issued
(``WebhooksManager::SignWebhook`` passes ``webhook.webhook_secret`` straight to
``OpenSSL::HMAC``). ``standardwebhooks.Webhook`` instead base64-decodes whatever it is
handed to derive its key, so handing it the secret raw derives the wrong key and every
genuine delivery fails to verify. Encoding here cancels that decode out, leaving
exactly the bytes the backend signed with.

The whole secret is encoded, prefix included, because the backend never strips a prefix
either. That also disarms the library's own ``whsec_`` stripping: base64 output cannot
begin with ``whsec_``, since ``_`` is not in the base64 alphabet.
"""
return base64.b64encode(key.encode("utf-8") if isinstance(key, str) else key).decode("ascii")


def unwrap(
payload: Union[str, bytes],
*,
headers: Mapping[str, str],
key: Union[str, bytes, None],
) -> Dict[str, Any]:
"""Verify ``payload`` against the signature headers and return the parsed body.

Args:
payload: The raw, unmodified request body. Verifying a re-serialized body fails:
the signature covers the exact bytes sent.
headers: The request headers. Only ``webhook-id``, ``webhook-timestamp`` and
``webhook-signature`` are read, and the lookup is case-insensitive.
key: The endpoint's signing secret, exactly as Whop shows it — a ``ws_``-prefixed
string. Pass it verbatim; do not strip the prefix and do not pre-encode it.

Returns:
The parsed body.

Raises:
ValueError: when ``key`` is missing or empty, or the verified body is not a
JSON object.
WebhookVerificationError: when a signature header is missing or malformed, the
timestamp is outside the tolerance window, or no signature matches.
"""
if not key:
raise ValueError(MISSING_KEY_MESSAGE)

try:
Webhook(_hmac_key(key)).verify(payload, dict(headers), json_parse=False)
except WebhookVerificationError:
raise
except Exception as error:
# standardwebhooks lets a malformed webhook-signature header escape as a bare
# ValueError or binascii.Error rather than a WebhookVerificationError, and that
# header is attacker-controlled. Rejecting is right; the exception type is not,
# so callers can catch one thing.
raise WebhookVerificationError(f"Invalid signature headers: {error}") from error

event = json.loads(payload)
if not isinstance(event, dict):
raise ValueError(f"Expected the webhook body to be a JSON object, got {type(event).__name__}")
return event
228 changes: 228 additions & 0 deletions tests/custom/test_verify_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import base64
import hashlib
import hmac
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, Optional, Union

import pytest
from standardwebhooks import WebhookVerificationError

from whop_sdk.lib.verify_webhook import unwrap

# The format WebhooksManager::Create issues: "ws_" + SecureRandom.hex(32).
KEY = "ws_" + "3f2a" * 16
OTHER_KEY = "ws_" + "c17b" * 16
PAYLOAD = '{"id":"evt_123","event":"payment.succeeded","data":{"id":"pay_123"}}'


def backend_signature(payload: Union[str, bytes], key: str, msg_id: str, timestamp: str) -> str:
"""Reproduce backend/app/services/webhooks_manager/sign_webhook.rb.

Deliberately not the library under test. Signing and verifying with the same library is
self-consistent and proved nothing: it agreed with itself while rejecting every genuine
Whop delivery.

payload = "#{id}.#{timestamp}.#{body_json}"
raw_sig = OpenSSL::HMAC.digest("sha256", secret, payload)
signature = Base64.strict_encode64(raw_sig)
header = "v1,#{signature}"
"""
body = payload if isinstance(payload, bytes) else payload.encode("utf-8")
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + body
return base64.b64encode(hmac.new(key.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii")


def signed_headers(
payload: Union[str, bytes] = PAYLOAD,
key: str = KEY,
msg_id: str = "msg_2Xa9",
timestamp: Optional[datetime] = None,
) -> Dict[str, str]:
at = timestamp if timestamp is not None else datetime.now(tz=timezone.utc)
ts = str(int(at.timestamp()))
return {
"webhook-id": msg_id,
"webhook-timestamp": ts,
"webhook-signature": "v1," + backend_signature(payload, key, msg_id, ts),
}


def test_returns_the_parsed_body_for_a_valid_signature() -> None:
event = unwrap(PAYLOAD, headers=signed_headers(), key=KEY)

assert event == {"id": "evt_123", "event": "payment.succeeded", "data": {"id": "pay_123"}}


def test_accepts_a_bytes_payload() -> None:
assert unwrap(PAYLOAD.encode(), headers=signed_headers(), key=KEY)["id"] == "evt_123"


def test_accepts_headers_whose_names_are_capitalized() -> None:
headers = {name.title(): value for name, value in signed_headers().items()}

assert unwrap(PAYLOAD, headers=headers, key=KEY)["id"] == "evt_123"


def test_signs_over_the_exact_bytes_of_the_body() -> None:
payload = '{"id":"evt_123","note":"a\\u00e9b","emoji":"\U0001f600"}'.encode("utf-8")
headers = signed_headers(payload=payload)

signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode("utf-8") + payload
expected = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii")

assert headers["webhook-signature"] == f"v1,{expected}"
assert unwrap(payload, headers=headers, key=KEY)["id"] == "evt_123"


def test_uses_the_secret_verbatim_without_stripping_a_prefix() -> None:
# The backend HMACs the stored secret as-is, so a secret and that same secret minus a
# prefix are two different keys. Stripping either one would silently derive the wrong key.
prefixed = "whsec_" + "9d4e" * 16
bare = prefixed.removeprefix("whsec_")

assert unwrap(PAYLOAD, headers=signed_headers(key=prefixed), key=prefixed)["id"] == "evt_123"
with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=signed_headers(key=prefixed), key=bare)


@pytest.mark.parametrize("template", ["v1,{filler} {valid}", "{valid} v1,{filler}", "v0,{filler} {valid} v2,{filler}"])
def test_accepts_a_valid_v1_entry_in_a_multi_signature_header(template: str) -> None:
headers = signed_headers()
value = template.format(valid=headers["webhook-signature"], filler="A" * 44)

assert unwrap(PAYLOAD, headers={**headers, "webhook-signature": value}, key=KEY)["id"] == "evt_123"


def test_ignores_a_v1n_entry_and_verifies_the_v1_entry_beside_it() -> None:
"""The nonce-bound scheme from https://github.com/whopio/whop/pull/23394:
base64(HMAC(secret, "v1n.<id>.<timestamp>.<nonce>.<body>")), appended after the v1
entry. That PR is closed and no deployed sender emits it — WebhooksManager::SignWebhook
on main writes a single "v1,<sig>" entry — so the helper ignores v1n rather than
verifying it. This pins that ignoring it stays harmless if the scheme ever ships: the v1
entry it travels beside is still the one that authenticates the delivery."""
headers = signed_headers()
msg_id, ts, nonce = headers["webhook-id"], headers["webhook-timestamp"], "nonce_zRq4"
signed = f"v1n.{msg_id}.{ts}.{nonce}.{PAYLOAD}".encode("utf-8")
v1n = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii")
headers = {**headers, "webhook-nonce": nonce, "webhook-signature": f"{headers['webhook-signature']} v1n,{v1n}"}

assert unwrap(PAYLOAD, headers=headers, key=KEY)["id"] == "evt_123"


def test_rejects_a_header_carrying_only_a_v1n_entry() -> None:
headers = signed_headers()
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
signed = f"v1n.{msg_id}.{ts}.nonce_zRq4.{PAYLOAD}".encode("utf-8")
v1n = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii")

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers={**headers, "webhook-signature": f"v1n,{v1n}"}, key=KEY)


def test_rejects_a_tampered_payload() -> None:
with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD.replace("pay_123", "pay_456"), headers=signed_headers(), key=KEY)


def test_rejects_a_payload_reserialized_with_the_same_content() -> None:
reserialized = json.dumps(json.loads(PAYLOAD), indent=2)

assert reserialized != PAYLOAD
assert json.loads(reserialized) == json.loads(PAYLOAD)
with pytest.raises(WebhookVerificationError):
unwrap(reserialized, headers=signed_headers(), key=KEY)


def test_rejects_a_signature_made_with_a_different_key() -> None:
with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=signed_headers(key=OTHER_KEY), key=KEY)


def test_rejects_a_signature_bound_to_a_different_message_id() -> None:
headers = {**signed_headers(msg_id="msg_original"), "webhook-id": "msg_replaced"}

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=headers, key=KEY)


def test_rejects_a_signature_bound_to_a_different_timestamp() -> None:
now = datetime.now(tz=timezone.utc)
headers = {**signed_headers(timestamp=now), "webhook-timestamp": str(int((now - timedelta(minutes=1)).timestamp()))}

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=headers, key=KEY)


@pytest.mark.parametrize("offset", [timedelta(minutes=-10), timedelta(minutes=10)])
def test_rejects_a_timestamp_outside_the_tolerance_window(offset: timedelta) -> None:
stale = datetime.now(tz=timezone.utc) + offset

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=signed_headers(timestamp=stale), key=KEY)


@pytest.mark.parametrize("dropped", ["webhook-id", "webhook-timestamp", "webhook-signature"])
def test_rejects_each_missing_signature_header(dropped: str) -> None:
headers = {name: value for name, value in signed_headers().items() if name != dropped}

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=headers, key=KEY)


@pytest.mark.parametrize(
"signature",
["not-a-signature", "v1,", "v1,!!!!", "v2,abc", "", "v1,a,b", "v1," + "A" * 44],
)
def test_rejects_a_malformed_signature_header(signature: str) -> None:
"""standardwebhooks lets some of these escape as a bare ValueError; the helper
normalizes every rejection onto WebhookVerificationError so callers catch one thing."""
headers = {**signed_headers(), "webhook-signature": signature}

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=headers, key=KEY)


@pytest.mark.parametrize("timestamp", ["not-a-timestamp", "", "1e999"])
def test_rejects_an_unparsable_timestamp(timestamp: str) -> None:
headers = {**signed_headers(), "webhook-timestamp": timestamp}

with pytest.raises(WebhookVerificationError):
unwrap(PAYLOAD, headers=headers, key=KEY)


@pytest.mark.parametrize("key", [None, "", b""])
def test_raises_a_clear_error_when_the_key_is_missing(key: Union[str, bytes, None]) -> None:
with pytest.raises(ValueError, match="without a key"):
unwrap(PAYLOAD, headers=signed_headers(), key=key)


def test_raises_before_verifying_when_the_key_is_missing() -> None:
with pytest.raises(ValueError, match="without a key"):
unwrap(PAYLOAD, headers={}, key=None)


def test_rejects_a_verified_body_that_is_not_a_json_object() -> None:
payload = "[1, 2, 3]"

with pytest.raises(ValueError, match="JSON object"):
unwrap(payload, headers=signed_headers(payload=payload), key=KEY)


def test_pyproject_declares_standardwebhooks() -> None:
"""src/whop_sdk/lib is kept by .fernignore, but pyproject.toml is generated: the
dependency is re-declared from extra_dependencies in the python config in
whop-monorepo sdks/fern/generators.yml. Without it, importing this helper from a
clean install raises ImportError."""
# tomllib is 3.11+ and this SDK supports 3.10, so read the section by hand.
pyproject = (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text()
section = re.search(r"^\[tool\.poetry\.dependencies\]\n(.*?)(?=^\[)", pyproject, re.S | re.M)

assert section is not None, "pyproject.toml has no [tool.poetry.dependencies] section"
assert re.search(r"^standardwebhooks\s*=", section.group(1), re.M), (
"pyproject.toml must declare standardwebhooks. It is generated, so .fernignore "
"does not preserve the declaration: set extra_dependencies in the python config "
"in whop-monorepo sdks/fern/generators.yml."
)
Loading