fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)
Some checks failed
lint.yml / fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2) (push) Failing after 0s

`python-lnurl`'s `lnurl_encode` rejects HTTP URLs whose host isn't
`localhost`/`127.0.0.1`/`.onion`, so a regtest LNbits on a LAN IP
(e.g. `http://192.168.0.32:5001`) made `_populate_lnurl` swallow
`InvalidUrl` and leave `link.lnurl=None` — breaking the LAN-local
cross-device smoke flow.

Extend the existing localhost carve-out to the full RFC1918 set:
loopback, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. These are
intrinsically unreachable from the public internet, so producing an
HTTP LNURL pointing at one is unambiguously a dev/internal scenario.
For matching URLs, skip `lnurl_encode`'s host validation by calling
the public `lnurl.helpers.url_encode` directly (which bech32-encodes
without URL validation). Everything else still goes through the
validated path — production with HTTP + public IP/hostname stays
rejected.

`create_lnurl_from_baseurl` now returns `(bech32, url)` directly
rather than a `Lnurl` instance, since the private-network branch
can't construct a real `Lnurl` (its `__new__` re-runs the same host
validation on bech32-decode). The caller `_populate_lnurl` was the
only consumer.

Test coverage on `_is_private_network_http` covers the carve-out
boundary (loopback, RFC1918, the just-outside-RFC1918 ranges, public
hosts, and the `https://` case). The full encode path is exercised
via regtest smoke.

Closes #2.
This commit is contained in:
Padreug 2026-06-01 21:14:48 +02:00
commit 66026abe96
3 changed files with 107 additions and 5 deletions

View file

@ -1,7 +1,11 @@
import ipaddress
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
@ -29,12 +33,41 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
) from e
def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
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 create_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]:
"""
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:
@ -45,10 +78,14 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
else:
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
if _is_private_network_http(url):
return url_encode(url), url
try:
return lnurl_encode(url)
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)