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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 18 additions & 4 deletions branta/v2/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
11 changes: 11 additions & 0 deletions branta/v2/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 125 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading