diff --git a/helpers.py b/helpers.py index 31efcff..b975f22 100644 --- a/helpers.py +++ b/helpers.py @@ -1,13 +1,73 @@ +import ipaddress +from dataclasses import dataclass +from urllib.parse import urlparse + from fastapi import Request from lnbits.settings import settings -from lnurl import Lnurl from lnurl import encode as lnurl_encode +from lnurl.helpers import url_encode from shortuuid import uuid from .models import WithdrawLink -def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: +@dataclass(frozen=True) +class _EncodedLnurl: + """Minimal duck-typed stand-in for `lnurl.Lnurl` exposing just the + `.bech32` and `.url` attributes the callers (views.py, views_api.py, + transport_rpcs.py) read. We can't always return a real `Lnurl` — + its `__new__` re-runs python-lnurl's HTTPS-required host check on + bech32-decode, which rejects RFC1918/loopback HTTP URLs even after + we've intentionally encoded one. See #2. + """ + + bech32: str + url: str + + +def _is_private_network_http(url: str) -> bool: + """True iff `url` is `http://` AND the host is loopback, `localhost`, + or in an RFC1918 private range. These are intrinsically unreachable + from the public internet, so producing an HTTP LNURL pointing at one + is a dev/internal-test scenario, not a production misconfig. + + Extends python-lnurl's existing `localhost` / `127.0.0.1` carve-out + (see `lnurl.types.INSECURE_HOSTS`) to the full RFC1918 set so + cross-device regtest smoke against a LAN-IP LNbits works without + standing up TLS termination. + """ + parsed = urlparse(url) + if parsed.scheme != "http": + return False + host = (parsed.hostname or "").lower() + if host == "localhost": + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return ip.is_loopback or ip.is_private + + +def _encode_lnurl(url: str, error_hint: str) -> _EncodedLnurl: + """Bech32-encode `url` as an LNURL. LAN-local HTTP URLs (loopback / + RFC1918) skip python-lnurl's HTTPS-required host validation via the + public `lnurl.helpers.url_encode` helper. Everything else goes + through the validated `lnurl_encode` path. See #2. + """ + if _is_private_network_http(url): + return _EncodedLnurl(bech32=url_encode(url), url=url) + + try: + encoded = lnurl_encode(url) + except Exception as e: + raise ValueError( + f"Error creating LNURL with url: `{url!s}`, {error_hint}" + ) from e + return _EncodedLnurl(bech32=str(encoded.bech32), url=str(encoded.url)) + + +def create_lnurl(link: WithdrawLink, req: Request) -> _EncodedLnurl: if link.is_unique: usescssv = link.usescsv.split(",") tohash = link.id + link.unique_hash + usescssv[link.number] @@ -20,16 +80,10 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: else: url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) - try: - return lnurl_encode(str(url)) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your webserver proxy configuration." - ) from e + return _encode_lnurl(str(url), "check your webserver proxy configuration.") -def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: +def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl: """ Same shape as `create_lnurl`, but composes the callback URL from `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by @@ -45,10 +99,4 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - try: - return lnurl_encode(url) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your `LNBITS_BASEURL` configuration." - ) from e + return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.") diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..01e1e69 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,67 @@ +""" +Unit tests for the private-network HTTP detector that gates the +plain-HTTP carve-out in `create_lnurl_from_baseurl`. See #2. + +The full encode path is exercised end-to-end during regtest smoke; +these tests cover the pure host-classification logic so changes to +the carve-out boundary (e.g., adding `.onion`) can be regression-tested +without spinning up a wallet/settings fixture. +""" + +import pytest + +from ..helpers import _is_private_network_http + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost/withdraw/api/v1/lnurl/abc", + "http://localhost:5000/withdraw/api/v1/lnurl/abc", + "http://127.0.0.1/withdraw/api/v1/lnurl/abc", + "http://127.0.0.1:5000/withdraw/api/v1/lnurl/abc", + "http://10.0.0.5:5000/withdraw/api/v1/lnurl/abc", + "http://172.16.0.5:5000/withdraw/api/v1/lnurl/abc", + "http://172.31.255.255:5000/withdraw/api/v1/lnurl/abc", + "http://192.168.0.32:5001/withdraw/api/v1/lnurl/abc", + "http://192.168.255.255/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_accepts_loopback_and_rfc1918(url): + assert _is_private_network_http(url) is True + + +@pytest.mark.parametrize( + "url", + [ + # Public IPv4 + "http://8.8.8.8/withdraw/api/v1/lnurl/abc", + "http://1.1.1.1/withdraw/api/v1/lnurl/abc", + # Just-outside RFC1918 + "http://11.0.0.1/withdraw/api/v1/lnurl/abc", + "http://172.15.0.1/withdraw/api/v1/lnurl/abc", + "http://172.32.0.1/withdraw/api/v1/lnurl/abc", + "http://192.167.0.1/withdraw/api/v1/lnurl/abc", + "http://192.169.0.1/withdraw/api/v1/lnurl/abc", + # Public hostnames (not an IP literal, not localhost) + "http://example.com/withdraw/api/v1/lnurl/abc", + "http://lnbits.example.com/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_rejects_public_hosts(url): + assert _is_private_network_http(url) is False + + +@pytest.mark.parametrize( + "url", + [ + "https://localhost/withdraw/api/v1/lnurl/abc", + "https://127.0.0.1/withdraw/api/v1/lnurl/abc", + "https://192.168.0.32/withdraw/api/v1/lnurl/abc", + "https://example.com/withdraw/api/v1/lnurl/abc", + ], +) +def test_is_private_network_http_rejects_https_scheme(url): + """Detector only fires for `http://`. `https://` always takes the + validated `lnurl_encode` path (which accepts any host).""" + assert _is_private_network_http(url) is False diff --git a/transport_rpcs.py b/transport_rpcs.py index 193d7c3..63db744 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -212,8 +212,8 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: """ try: encoded = create_lnurl_from_baseurl(link) - link.lnurl = str(encoded.bech32) - link.lnurl_url = str(encoded.url) + link.lnurl = encoded.bech32 + link.lnurl_url = encoded.url except ValueError: pass return link