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
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:
parent
e9d911e593
commit
66026abe96
3 changed files with 107 additions and 5 deletions
41
helpers.py
41
helpers.py
|
|
@ -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
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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue