Compare commits

...

2 commits

Author SHA1 Message Date
2877cf6b20 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.
2026-06-01 21:44:57 +02:00
0e06ab2087 Revert "fix: extend RFC1918 LNURL carve-out to the HTTP-views path"
This reverts commit 40dce41.

Going with TLS termination on the dev LNbits instead, so the
RFC1918 carve-out becomes unnecessary. The lnbits-core
`/api/v1/lnurlscan` consumer-side validator applies the same
HTTPS-required rule python-lnurl enforces; carving the producer
side out only got greg's LNURL generated, not redeemed.
2026-06-01 21:43:37 +02:00
3 changed files with 19 additions and 134 deletions

View file

@ -1,73 +1,13 @@
import ipaddress
from dataclasses import dataclass
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 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
@dataclass(frozen=True) def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
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: if link.is_unique:
usescssv = link.usescsv.split(",") usescssv = link.usescsv.split(",")
tohash = link.id + link.unique_hash + usescssv[link.number] tohash = link.id + link.unique_hash + usescssv[link.number]
@ -80,10 +20,16 @@ def create_lnurl(link: WithdrawLink, req: Request) -> _EncodedLnurl:
else: else:
url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
return _encode_lnurl(str(url), "check your webserver proxy configuration.") 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
def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl: def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
""" """
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
@ -99,4 +45,10 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl:
else: else:
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.") 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

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

@ -212,8 +212,8 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
""" """
try: try:
encoded = create_lnurl_from_baseurl(link) encoded = create_lnurl_from_baseurl(link)
link.lnurl = encoded.bech32 link.lnurl = str(encoded.bech32)
link.lnurl_url = encoded.url link.lnurl_url = str(encoded.url)
except ValueError: except ValueError:
pass pass
return link return link