From dc5fe26e7d0cdbda41f3d30696a532894f8f767b Mon Sep 17 00:00:00 2001 From: Kyle McCullen Date: Mon, 14 Sep 2026 22:11:17 -0400 Subject: [PATCH] Harden --- CLAUDE.md | 2 +- branta/v2/client.py | 22 +++++-- branta/v2/serialization.py | 11 ++++ tests/test_client.py | 125 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 tests/test_client.py diff --git a/CLAUDE.md b/CLAUDE.md index 2dd82fe..b790de4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ pip install -e ".[dev]" - **ZK encryption:** Bitcoin addresses use a random secret (GUID); hash-ZK types (bolt11, ark, silent_payment) use a deterministic key from SHA256(normalized value). `add_payment` mutates `payment.destinations[*].value` to the encrypted form before POSTing. - **Metadata DEK-envelope:** If a payment has metadata and any ZK destination, a separate DEK is generated, metadata is encrypted with it, and the DEK is encrypted per-destination. - **Never surface lookup failures.** Swallow decryption errors; leave `is_encrypted=True` and value unchanged. -- **Domain validation.** `platform_logo_url` must match `base_url` domain. +- **Domain validation.** `platform_logo_url`, `platform_logo_light_url`, `parent_platform.logo_url`/`logo_light_url`, and `child_platform.logo_url`/`logo_light_url` must each match `base_url`'s origin. Every payment in a GET response is checked, not just the first. ## Conventions diff --git a/branta/v2/client.py b/branta/v2/client.py index 42d18a8..797f542 100644 --- a/branta/v2/client.py +++ b/branta/v2/client.py @@ -151,13 +151,27 @@ def _verify_logo_urls(self, base_url: str, payments: List[Payment]) -> None: base_origin = f"{urlparse(base_url).scheme}://{urlparse(base_url).netloc}" except Exception: return - for payment in payments: - logo_url = payment.platform_logo_url + + def check(logo_url: Optional[str], field_name: str) -> None: if not logo_url: return try: logo_origin = f"{urlparse(logo_url).scheme}://{urlparse(logo_url).netloc}" except Exception: - raise BrantaPaymentException("platformLogoUrl domain does not match the configured baseUrl domain") + raise BrantaPaymentException(f"{field_name} domain does not match the configured base_url domain") if logo_origin != base_origin: - raise BrantaPaymentException("platformLogoUrl domain does not match the configured baseUrl domain") + raise BrantaPaymentException(f"{field_name} domain does not match the configured base_url domain") + + for payment in payments: + check(payment.platform_logo_url, "platform_logo_url") + check(payment.platform_logo_light_url, "platform_logo_light_url") + check(payment.parent_platform.logo_url if payment.parent_platform else None, "parent_platform.logo_url") + check( + payment.parent_platform.logo_light_url if payment.parent_platform else None, + "parent_platform.logo_light_url", + ) + check(payment.child_platform.logo_url if payment.child_platform else None, "child_platform.logo_url") + check( + payment.child_platform.logo_light_url if payment.child_platform else None, + "child_platform.logo_light_url", + ) diff --git a/branta/v2/serialization.py b/branta/v2/serialization.py index 25d04bc..0801194 100644 --- a/branta/v2/serialization.py +++ b/branta/v2/serialization.py @@ -104,6 +104,17 @@ def payment_from_api(raw: Dict[str, Any]) -> Payment: if pp.get("logo_light_url") is not None: parent.logo_light_url = str(pp["logo_light_url"]) payment.parent_platform = parent + if raw.get("child_platform") is not None: + cp = raw["child_platform"] + if isinstance(cp, dict): + child = Platform() + if cp.get("name") is not None: + child.name = str(cp["name"]) + if cp.get("logo_url") is not None: + child.logo_url = str(cp["logo_url"]) + if cp.get("logo_light_url") is not None: + child.logo_light_url = str(cp["logo_light_url"]) + payment.child_platform = child if raw.get("btc_pay_server_plugin_version") is not None: payment.btc_pay_server_plugin_version = str(raw["btc_pay_server_plugin_version"]) return payment diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f51fbb4 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json + +import pytest + +from branta.enums import BrantaServerBaseUrl, PrivacyMode +from branta.exceptions import BrantaPaymentException +from branta.options import BrantaClientOptions +from branta.v2.client import BrantaClient + +# Matches BrantaServerBaseUrl.Localhost's value. +SAME_ORIGIN = "http://localhost:3000" +OTHER_ORIGIN = "https://attacker.example" + +DESTINATIONS = [{"value": "test-destination"}] + + +class _FakeResponse: + def __init__(self, status: int, body: str) -> None: + self.status = status + self._body = body + + @property + def ok(self) -> bool: + return 200 <= self.status < 300 + + async def text(self) -> str: + return self._body + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + +class _FakeSession: + def __init__(self, response: _FakeResponse) -> None: + self._response = response + + def get(self, url: str, headers: dict | None = None) -> _FakeResponse: + return self._response + + +def client_with_response(body: object) -> BrantaClient: + session = _FakeSession(_FakeResponse(200, json.dumps(body))) + options = BrantaClientOptions(base_url=BrantaServerBaseUrl.Localhost, privacy=PrivacyMode.Loose) + return BrantaClient(default_options=options, session=session) # type: ignore[arg-type] + + +async def test_checks_every_payments_logo_not_just_the_first() -> None: + client = client_with_response( + [ + {"destinations": DESTINATIONS}, + {"destinations": DESTINATIONS, "platform_logo_url": f"{OTHER_ORIGIN}/logo.png"}, + ] + ) + + with pytest.raises(BrantaPaymentException): + await client.get_payments("value") + + +async def test_catches_mismatched_platform_logo_light_url() -> None: + client = client_with_response( + [{"destinations": DESTINATIONS, "platform_logo_light_url": f"{OTHER_ORIGIN}/logo-light.png"}] + ) + + with pytest.raises(BrantaPaymentException, match="platform_logo_light_url"): + await client.get_payments("value") + + +async def test_catches_mismatched_parent_platform_logo_url() -> None: + client = client_with_response( + [{"destinations": DESTINATIONS, "parent_platform": {"logo_url": f"{OTHER_ORIGIN}/logo.png"}}] + ) + + with pytest.raises(BrantaPaymentException, match="parent_platform.logo_url"): + await client.get_payments("value") + + +async def test_catches_mismatched_parent_platform_logo_light_url() -> None: + client = client_with_response( + [{"destinations": DESTINATIONS, "parent_platform": {"logo_light_url": f"{OTHER_ORIGIN}/logo-light.png"}}] + ) + + with pytest.raises(BrantaPaymentException, match="parent_platform.logo_light_url"): + await client.get_payments("value") + + +async def test_catches_mismatched_child_platform_logo_url() -> None: + client = client_with_response( + [{"destinations": DESTINATIONS, "child_platform": {"logo_url": f"{OTHER_ORIGIN}/logo.png"}}] + ) + + with pytest.raises(BrantaPaymentException, match="child_platform.logo_url"): + await client.get_payments("value") + + +async def test_catches_mismatched_child_platform_logo_light_url() -> None: + client = client_with_response( + [{"destinations": DESTINATIONS, "child_platform": {"logo_light_url": f"{OTHER_ORIGIN}/logo-light.png"}}] + ) + + with pytest.raises(BrantaPaymentException, match="child_platform.logo_light_url"): + await client.get_payments("value") + + +async def test_does_not_throw_when_all_logo_fields_are_same_origin_or_absent() -> None: + client = client_with_response( + [ + { + "destinations": DESTINATIONS, + "platform_logo_url": f"{SAME_ORIGIN}/a.png", + "platform_logo_light_url": f"{SAME_ORIGIN}/b.png", + "parent_platform": {"logo_url": f"{SAME_ORIGIN}/c.png", "logo_light_url": f"{SAME_ORIGIN}/d.png"}, + "child_platform": {"logo_url": f"{SAME_ORIGIN}/e.png"}, + }, + {"destinations": DESTINATIONS}, + ] + ) + + payments = await client.get_payments("value") + + assert len(payments) == 2