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 fastapi import Request
from lnbits.settings import settings from lnbits.settings import settings
from lnurl import Lnurl from lnurl import Lnurl
from lnurl import encode as lnurl_encode from lnurl import encode as lnurl_encode
from lnurl.helpers import url_encode
from shortuuid import uuid from shortuuid import uuid
from .models import WithdrawLink from .models import WithdrawLink
@ -29,12 +33,41 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
) from e ) 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 Same shape as `create_lnurl`, but composes the callback URL from
`settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by
the nostr-transport RPC handlers, which have no HTTP request to the nostr-transport RPC handlers, which have no HTTP request to
derive a base URL from. 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("/") base = settings.lnbits_baseurl.rstrip("/")
if link.is_unique: if link.is_unique:
@ -45,10 +78,14 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
else: else:
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
if _is_private_network_http(url):
return url_encode(url), url
try: try:
return lnurl_encode(url) encoded = lnurl_encode(url)
except Exception as e: except Exception as e:
raise ValueError( raise ValueError(
f"Error creating LNURL with url: `{url!s}`, " f"Error creating LNURL with url: `{url!s}`, "
"check your `LNBITS_BASEURL` configuration." "check your `LNBITS_BASEURL` configuration."
) from e ) from e
return str(encoded.bech32), str(encoded.url)

67
tests/test_helpers.py Normal file
View file

@ -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

View file

@ -211,9 +211,7 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
duplicating state LNbits already knows. See aiolabs/withdraw#1. duplicating state LNbits already knows. See aiolabs/withdraw#1.
""" """
try: try:
encoded = create_lnurl_from_baseurl(link) link.lnurl, link.lnurl_url = create_lnurl_from_baseurl(link)
link.lnurl = str(encoded.bech32)
link.lnurl_url = str(encoded.url)
except ValueError: except ValueError:
pass pass
return link return link