Compare commits

..

No commits in common. "2877cf6b20dcea1f2f79d2ccffb7419ea9a6a8b7" and "40dce4d88c317a75068d0b8d89e16917b2a27baa" have entirely different histories.

3 changed files with 134 additions and 19 deletions

View file

@ -1,13 +1,73 @@
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
def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: @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.
"""
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]
@ -20,16 +80,10 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
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)
try: return _encode_lnurl(str(url), "check your webserver proxy configuration.")
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) -> Lnurl: def create_lnurl_from_baseurl(link: WithdrawLink) -> _EncodedLnurl:
""" """
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
@ -45,10 +99,4 @@ 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}"
try: return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.")
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

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

@ -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 = str(encoded.bech32) link.lnurl = encoded.bech32
link.lnurl_url = str(encoded.url) link.lnurl_url = encoded.url
except ValueError: except ValueError:
pass pass
return link return link