Compare commits
No commits in common. "2877cf6b20dcea1f2f79d2ccffb7419ea9a6a8b7" and "40dce4d88c317a75068d0b8d89e16917b2a27baa" have entirely different histories.
2877cf6b20
...
40dce4d88c
3 changed files with 134 additions and 19 deletions
82
helpers.py
82
helpers.py
|
|
@ -1,13 +1,73 @@
|
|||
import ipaddress
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
usescssv = link.usescsv.split(",")
|
||||
tohash = link.id + link.unique_hash + usescssv[link.number]
|
||||
|
|
@ -20,16 +80,10 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
|
|||
else:
|
||||
url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
|
||||
|
||||
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
|
||||
return _encode_lnurl(str(url), "check your webserver proxy configuration.")
|
||||
|
||||
|
||||
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
|
||||
`settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by
|
||||
|
|
@ -45,10 +99,4 @@ def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
|
|||
else:
|
||||
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
|
||||
|
||||
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
|
||||
return _encode_lnurl(url, "check your `LNBITS_BASEURL` configuration.")
|
||||
|
|
|
|||
67
tests/test_helpers.py
Normal file
67
tests/test_helpers.py
Normal 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
|
||||
|
|
@ -212,8 +212,8 @@ def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
|
|||
"""
|
||||
try:
|
||||
encoded = create_lnurl_from_baseurl(link)
|
||||
link.lnurl = str(encoded.bech32)
|
||||
link.lnurl_url = str(encoded.url)
|
||||
link.lnurl = encoded.bech32
|
||||
link.lnurl_url = encoded.url
|
||||
except ValueError:
|
||||
pass
|
||||
return link
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue