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

This reverts commit 66026ab.

Closes #2 as resolved by switching the dev LNbits to TLS
(self-signed cert) instead of carving out plain HTTP for
RFC1918 hosts. With HTTPS the producer-side python-lnurl
validation accepts any host, AND the lnbits-core consumer-side
`lnurlscan` accepts it too — the symmetric problem the carve-out
couldn't solve on its own.

`create_lnurl_from_baseurl` (#1, `e9d911e`) is kept — it's
orthogonal to the transport scheme and still wanted for the
nostr-transport `lnurl=null` fix.
This commit is contained in:
Padreug 2026-06-01 21:44:57 +02:00
commit 2877cf6b20
3 changed files with 5 additions and 107 deletions

View file

@ -1,11 +1,7 @@
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
@ -33,41 +29,12 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
) from e
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]:
def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
"""
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 +45,10 @@ 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)
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 str(encoded.bech32), str(encoded.url)

View file

@ -1,67 +0,0 @@
"""
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,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 = str(encoded.bech32)
link.lnurl_url = str(encoded.url)
except ValueError:
pass
return link