diff --git a/docker/justworks/business/server.py b/docker/justworks/business/server.py index a6a6829b..6bbbbe12 100644 --- a/docker/justworks/business/server.py +++ b/docker/justworks/business/server.py @@ -155,6 +155,23 @@ def assert_public_https(url): return url +class NoLnurlRedirect(HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + # A validated public endpoint cannot delegate access to another host or + # protocol, especially a private service reachable from this node. + raise ValueError("Lightning provider redirects are not permitted") + + +def fetch_lnurl_json(url): + endpoint = assert_public_https(url) + request = Request(endpoint, headers={"User-Agent": "JustWorks-Business/0.1"}) + with build_opener(NoLnurlRedirect).open(request, timeout=10) as response: + raw = response.read(65537) + if len(raw) > 65536: + raise ValueError("Lightning provider response too large") + return json.loads(raw) + + def lightning_invoice(lightning_address, amount_msat, comment=""): if "@" not in lightning_address: raise ValueError("Merchant Lightning address is invalid") @@ -162,7 +179,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""): if not name or not host or any(char in host for char in "/?#"): raise ValueError("Merchant Lightning address is invalid") endpoint = assert_public_https(f"https://{host}/.well-known/lnurlp/{name}") - pay = fetch_json(endpoint) + pay = fetch_lnurl_json(endpoint) if pay.get("tag") != "payRequest" or not pay.get("callback"): raise ValueError("Lightning address does not support payments") minimum, maximum = int(pay.get("minSendable", 0)), int(pay.get("maxSendable", 0)) @@ -186,7 +203,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""): params["comment"] = safe_comment[:int(pay["commentAllowed"])] separator = "&" if "?" in callback else "?" try: - invoice = fetch_json(f"{callback}{separator}{urlencode(params)}") + invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(params)}") except HTTPError: if not proof: raise @@ -195,7 +212,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""): # the result non-verifiable instead of blocking the customer. proof = None fallback = {key: value for key, value in params.items() if key not in {"nostr", "lnurl"}} - invoice = fetch_json(f"{callback}{separator}{urlencode(fallback)}") + invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(fallback)}") if invoice.get("status") == "ERROR" or not invoice.get("pr"): raise ValueError(invoice.get("reason", "Lightning invoice unavailable")) return {"bolt11": invoice["pr"], "zap_pubkey": proof["pubkey"] if proof else None, diff --git a/docker/justworks/business/test_lnurl_transport.py b/docker/justworks/business/test_lnurl_transport.py new file mode 100644 index 00000000..cfde72f9 --- /dev/null +++ b/docker/justworks/business/test_lnurl_transport.py @@ -0,0 +1,53 @@ +"""No-network checks of the LNURL transport trust boundary.""" +import io +import unittest +from email.message import Message +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.request import HTTPSHandler, build_opener +from urllib.response import addinfourl +import server + +class FixtureHttps(HTTPSHandler): + def __init__(self, body=b'{}', redirect=None, status=302): + super().__init__() + self.body, self.redirect, self.status, self.calls = body, redirect, status, [] + def https_open(self, request): + self.calls.append(request.full_url) + headers = Message() + if self.redirect: headers['Location'] = self.redirect + response = addinfourl(io.BytesIO(self.body), headers, request.full_url, self.status if self.redirect else 200) + response.msg = 'Found' if self.redirect else 'OK' + return response + +class LnurlTransportTests(unittest.TestCase): + def transport(self, fixture): + with patch('server.assert_public_https', side_effect=lambda value: value), patch('server.build_opener', side_effect=lambda *handlers: build_opener(*handlers, fixture)): + return server.fetch_lnurl_json('https://provider.example/lnurl') + def test_actual_redirect_chain_refuses_internal_and_public_destinations(self): + for destination in ['http://127.0.0.1/admin', 'https://10.0.0.1/private', 'https://other.example/callback']: + for status in [301, 302, 303, 307, 308]: + fixture = FixtureHttps(redirect=destination, status=status) + with self.subTest(destination=destination, status=status): + with self.assertRaisesRegex(ValueError, 'redirects are not permitted'): self.transport(fixture) + self.assertEqual(fixture.calls, ['https://provider.example/lnurl']) + def test_response_cap_precedes_json_parsing(self): + with self.assertRaisesRegex(ValueError, 'response too large'): self.transport(FixtureHttps(body=b' ' * 65537)) + def test_valid_bounded_json(self): + self.assertEqual(self.transport(FixtureHttps(body=b'{"tag":"payRequest"}')), {'tag':'payRequest'}) + def test_unsafe_initial_url_never_opens_transport(self): + with patch('server.assert_public_https', side_effect=ValueError('unsafe')), patch('server.build_opener') as opener: + with self.assertRaises(ValueError): server.fetch_lnurl_json('https://127.0.0.1/private') + opener.assert_not_called() + def test_metadata_and_invoice_use_restricted_transport(self): + pay = {'tag':'payRequest','callback':'https://provider.example/invoice','minSendable':1,'maxSendable':5000} + with patch('server.assert_public_https', side_effect=lambda value:value), patch('server.fetch_lnurl_json', side_effect=[pay,{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted: + result=server.lightning_invoice('merchant@provider.example',1000) + self.assertEqual(result['bolt11'],'fixture-invoice');self.assertEqual(fetch.call_count,2);unrestricted.assert_not_called() + def test_zap_fallback_uses_restricted_transport(self): + pay={'tag':'payRequest','callback':'https://provider.example/invoice','allowsNostr':True,'nostrPubkey':'a'*64} + with patch('server.assert_public_https',side_effect=lambda value:value), patch('server.helper',return_value={'event':{},'pubkey':'b'*64,'lnurl':'fixture-lnurl'}), patch('server.fetch_lnurl_json',side_effect=[pay,HTTPError('',400,'fixture',{},None),{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted: + result=server.lightning_invoice('merchant@provider.example',1000) + self.assertFalse(result['verifiable']);self.assertEqual(fetch.call_count,3);unrestricted.assert_not_called() + +if __name__=='__main__': unittest.main()