From 40dce4d88c317a75068d0b8d89e16917b2a27baa Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 21:35:04 +0200 Subject: [PATCH] fix: extend RFC1918 LNURL carve-out to the HTTP-views path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 added the loopback/RFC1918 carve-out to the nostr-transport helper (`create_lnurl_from_baseurl`) but `views.py` / `views_api.py` still call `create_lnurl`, which went straight through `lnurl_encode` and got the same `InvalidUrl` rejection. Visible as a 500 "Error creating LNURL … check your webserver proxy configuration." on the admin UI when LNbits itself is on `http://192.168.x.x:port`. Extract the encode + carve-out logic into `_encode_lnurl(url, hint)` and route both `create_lnurl` and `create_lnurl_from_baseurl` through it. Both now return the same `_EncodedLnurl` dataclass (a minimal duck for `.bech32`/`.url`) — `Lnurl` itself can't be returned in the LAN-local case because its `__new__` re-runs python-lnurl's host validation on bech32-decode. Call sites in views.py / views_api.py unchanged: they already access `.bech32` and `.url`, which the dataclass exposes. `_populate_lnurl` back to attribute access too. --- helpers.py | 85 ++++++++++++++++++++++++++--------------------- transport_rpcs.py | 4 ++- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/helpers.py b/helpers.py index 7a2bba5..b975f22 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,9 @@ 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 @@ -11,26 +11,18 @@ from shortuuid import uuid from .models import WithdrawLink -def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: - if link.is_unique: - usescssv = link.usescsv.split(",") - tohash = link.id + link.unique_hash + usescssv[link.number] - multihash = uuid(name=tohash) - url = req.url_for( - "withdraw.api_lnurl_multi_response", - unique_hash=link.unique_hash, - id_unique_hash=multihash, - ) - else: - url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) +@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. + """ - 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 + bech32: str + url: str def _is_private_network_http(url: str) -> bool: @@ -57,17 +49,46 @@ def _is_private_network_http(url: str) -> bool: return ip.is_loopback or ip.is_private -def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: +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] + multihash = uuid(name=tohash) + url = req.url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=link.unique_hash, + id_unique_hash=multihash, + ) + else: + url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) + + return _encode_lnurl(str(url), "check your webserver proxy configuration.") + + +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 the nostr-transport RPC handlers, which have no HTTP request to derive a base URL from. - - Returns `(bech32, url)` — the two fields `_populate_lnurl` writes - onto `WithdrawLink.lnurl` / `lnurl_url`. LAN-local HTTP URLs - (loopback / RFC1918) skip python-lnurl's HTTPS-required check via - the public `lnurl.helpers.url_encode` helper; see #2. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: @@ -78,14 +99,4 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - if _is_private_network_http(url): - return url_encode(url), url - - try: - encoded = 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 str(encoded.bech32), str(encoded.url) + return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.") diff --git a/transport_rpcs.py b/transport_rpcs.py index 0fac4d6..63db744 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -211,7 +211,9 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink: duplicating state LNbits already knows. See aiolabs/withdraw#1. """ try: - link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link) + encoded = create_lnurl_from_baseurl(link) + link.lnurl = encoded.bech32 + link.lnurl_url = encoded.url except ValueError: pass return link