create_lnurl_from_baseurl: allow HTTP for RFC1918 / loopback hosts (extend the localhost exception) #2

Closed
opened 2026-06-01 19:07:59 +00:00 by padreug · 1 comment
Owner

Context

Follow-up to #1 (e9d911e). The new create_lnurl_from_baseurl helper composes an LNURL from settings.lnbits_baseurl and runs it through python-lnurl's lnurl_encode, which validates the URL against the LNURL spec — HTTPS is required, with localhost / 127.0.0.1 accepted as a documented dev exception.

This is correct for production: a customer wallet on the public internet won't redeem a plain-HTTP LNURL, so producing one would just generate broken QRs.

But it breaks a real and intentional dev workflow: regtest LNbits running plain HTTP on a LAN IP (e.g., http://192.168.0.32:5001) for cross-device smoke tests where the customer's phone wallet is on the same LAN. lnurl_encode raises InvalidUrl, _populate_lnurl swallows the ValueError, link.lnurl stays None, and downstream consumers (aiolabs/lamassu-next ATM) fail loudly.

Reproduce:

from lnurl import encode
encode('http://192.168.0.32:5001/withdraw/api/v1/lnurl/abc')
# → lnurl.exceptions.InvalidUrl

The principled extension

python-lnurl's localhost/127.0.0.1 exception is exactly right for the underlying invariant: HTTP is only acceptable when the URL is intrinsically unreachable from the public internet, because that's where wallet leniency about the scheme actually matters. Loopback addresses fit that. So do all of RFC1918:

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16

Plus loopback (127.0.0.0/8) and localhost.

A customer wallet on the public internet can't reach any of these by IP. The only customers that can are LAN-local — which is, by definition, a dev/internal-test scenario. Extending the existing localhost exception to cover all of these:

  • Catches the actual production misconfig case (HTTP + public IP/hostname → lnurl_encode still rejects)
  • Lets the regtest smoke work end-to-end without standing up TLS termination
  • Self-documenting: the rule mirrors a standard network distinction (RFC1918 == private network == dev)
  • No new admin-facing setting / no audit surface to misconfigure (unlike lnbits_allow_local_http_lnurl or similar)

Proposed patch

In helpers.py, add a private-network detector and branch create_lnurl_from_baseurl accordingly. When the URL matches the dev rule, manually bech32-encode (bypassing lnurl_encode); otherwise let lnurl_encode enforce the spec.

import ipaddress
from urllib.parse import urlparse

from bech32 import bech32_encode, convertbits
from lnbits.settings import settings
from lnurl import Lnurl
from lnurl import encode as lnurl_encode
from shortuuid import uuid

from .models import WithdrawLink


def _is_private_network_http(url: str) -> bool:
    """True iff url is `http://` (not https) AND the host is loopback
    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 scenario, not a production misconfig.

    Extends the `localhost` / `127.0.0.1` carve-out that python-lnurl
    already allows.
    """
    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 _manual_bech32_lnurl(url: str) -> Lnurl:
    """Manual bech32 encoding, bypassing lnurl_encode's URL validation.
    Used only for private-network HTTP URLs (see _is_private_network_http).
    The customer wallet must be LAN-local to dereference these; spec-
    compliant wallets on the public internet will reject them anyway.
    """
    data = convertbits(url.encode("utf-8"), 8, 5)
    if data is None:
        raise ValueError(f"bech32 conversion failed for url: {url!s}")
    bech32_str = bech32_encode("lnurl", data)
    # Construct an Lnurl using the same shape lnurl_encode returns.
    # If the library's Lnurl constructor enforces a stricter shape than
    # this, fall back to wrapping in a minimal duck-typed object — but
    # the consumers (transport_rpcs._populate_lnurl) only read `.bech32`
    # and `.url`, so any object with those attrs works.
    return Lnurl(bech32_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.
    """
    base = settings.lnbits_baseurl.rstrip("/")
    if link.is_unique:
        usescssv = link.usescsv.split(",")
        tohash = link.id + link.unique_hash + usescssv[link.number]
        multihash = uuid(name=tohash)
        url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}"
    else:
        url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"

    if _is_private_network_http(url):
        return _manual_bech32_lnurl(url)

    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

(The Lnurl(...) constructor in _manual_bech32_lnurl may need to be replaced with a duck-typed object if the library's constructor itself validates — _populate_lnurl in transport_rpcs.py only reads .bech32 and .url, so a dataclass-style minimal object suffices.)

What changes downstream

For aiolabs/lamassu-next (the surfaced consumer): the cash-in flow against a regtest LNbits running plain HTTP on a LAN IP now works end-to-end without TLS termination. Customer phone wallet on the same LAN scans the QR, dereferences the HTTP callback, redeems.

Production with HTTPS LNbits: unchanged. _is_private_network_http returns False, the validated lnurl_encode path runs.

Production with HTTP + public IP/hostname: still rejected. The detector returns False for public IPs, lnurl_encode raises, _populate_lnurl swallows, link.lnurl stays None, consumers fail loudly. The misconfig case stays caught.

Why not the other shapes

  • Bypass validation entirely ("admin knows what they're doing") — they don't, that's the whole point of having the validation. Rejected upstream and rejected here.
  • Add an admin-facing lnbits_allow_local_http_lnurl flag — explicit opt-in is clearer than the implicit-IP-detector, but introduces a new audit surface that can be turned on by mistake in production. The RFC1918 rule self-documents.
  • Push the dev workaround back to the consumer (bitspire-side) — re-introduces the VITE_LNBITS_HTTP_URL env var that aiolabs/withdraw#1 / e9d911e just deleted. Defeats the source-of-truth alignment.

Tests

  • test_create_lnurl_from_baseurl_http_localhost_succeeds
  • test_create_lnurl_from_baseurl_http_127_0_0_1_succeeds
  • test_create_lnurl_from_baseurl_http_rfc1918_succeeds (parametrized over 10.0.0.5, 172.16.0.5, 192.168.0.5)
  • test_create_lnurl_from_baseurl_http_public_ip_raises_value_error
  • test_create_lnurl_from_baseurl_https_public_succeeds
  • test_create_lnurl_from_baseurl_http_unresolvable_hostname_raises

Cross-refs

  • Parent: aiolabs/withdraw#1 (e9d911e) — the original nostr-transport link.lnurl=null fix
  • Surfaced in cross-session coordination log 2026-06-01 evening — gap-2 smoke against the regtest setup at http://192.168.0.32:5001 showed the python-lnurl validation rejecting the LAN-IP HTTP URL
  • Bitspire consumer that triggered the discovery: aiolabs/lamassu-next:apps/machine/src/services/lightning.ts:692-707 (the new link.lnurl consumption path)
## Context Follow-up to #1 (`e9d911e`). The new `create_lnurl_from_baseurl` helper composes an LNURL from `settings.lnbits_baseurl` and runs it through `python-lnurl`'s `lnurl_encode`, which validates the URL against the LNURL spec — HTTPS is required, with `localhost` / `127.0.0.1` accepted as a documented dev exception. This is correct for production: a customer wallet on the public internet won't redeem a plain-HTTP LNURL, so producing one would just generate broken QRs. But it breaks a real and intentional dev workflow: regtest LNbits running plain HTTP on a LAN IP (e.g., `http://192.168.0.32:5001`) for cross-device smoke tests where the customer's phone wallet is on the same LAN. `lnurl_encode` raises `InvalidUrl`, `_populate_lnurl` swallows the `ValueError`, `link.lnurl` stays `None`, and downstream consumers (`aiolabs/lamassu-next` ATM) fail loudly. Reproduce: ```python from lnurl import encode encode('http://192.168.0.32:5001/withdraw/api/v1/lnurl/abc') # → lnurl.exceptions.InvalidUrl ``` ## The principled extension `python-lnurl`'s `localhost`/`127.0.0.1` exception is exactly right for the underlying invariant: **HTTP is only acceptable when the URL is intrinsically unreachable from the public internet**, because that's where wallet leniency about the scheme actually matters. Loopback addresses fit that. So do all of RFC1918: - `10.0.0.0/8` - `172.16.0.0/12` - `192.168.0.0/16` Plus loopback (`127.0.0.0/8`) and `localhost`. A customer wallet on the public internet can't reach any of these by IP. The only customers that can are LAN-local — which is, by definition, a dev/internal-test scenario. Extending the existing `localhost` exception to cover all of these: - ✅ Catches the actual production misconfig case (HTTP + public IP/hostname → `lnurl_encode` still rejects) - ✅ Lets the regtest smoke work end-to-end without standing up TLS termination - ✅ Self-documenting: the rule mirrors a standard network distinction (RFC1918 == private network == dev) - ✅ No new admin-facing setting / no audit surface to misconfigure (unlike `lnbits_allow_local_http_lnurl` or similar) ## Proposed patch In `helpers.py`, add a private-network detector and branch `create_lnurl_from_baseurl` accordingly. When the URL matches the dev rule, manually bech32-encode (bypassing `lnurl_encode`); otherwise let `lnurl_encode` enforce the spec. ```python import ipaddress from urllib.parse import urlparse from bech32 import bech32_encode, convertbits from lnbits.settings import settings from lnurl import Lnurl from lnurl import encode as lnurl_encode from shortuuid import uuid from .models import WithdrawLink def _is_private_network_http(url: str) -> bool: """True iff url is `http://` (not https) AND the host is loopback 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 scenario, not a production misconfig. Extends the `localhost` / `127.0.0.1` carve-out that python-lnurl already allows. """ 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 _manual_bech32_lnurl(url: str) -> Lnurl: """Manual bech32 encoding, bypassing lnurl_encode's URL validation. Used only for private-network HTTP URLs (see _is_private_network_http). The customer wallet must be LAN-local to dereference these; spec- compliant wallets on the public internet will reject them anyway. """ data = convertbits(url.encode("utf-8"), 8, 5) if data is None: raise ValueError(f"bech32 conversion failed for url: {url!s}") bech32_str = bech32_encode("lnurl", data) # Construct an Lnurl using the same shape lnurl_encode returns. # If the library's Lnurl constructor enforces a stricter shape than # this, fall back to wrapping in a minimal duck-typed object — but # the consumers (transport_rpcs._populate_lnurl) only read `.bech32` # and `.url`, so any object with those attrs works. return Lnurl(bech32_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. """ base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: usescssv = link.usescsv.split(",") tohash = link.id + link.unique_hash + usescssv[link.number] multihash = uuid(name=tohash) url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}" else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" if _is_private_network_http(url): return _manual_bech32_lnurl(url) 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 ``` (The `Lnurl(...)` constructor in `_manual_bech32_lnurl` may need to be replaced with a duck-typed object if the library's constructor itself validates — `_populate_lnurl` in `transport_rpcs.py` only reads `.bech32` and `.url`, so a `dataclass`-style minimal object suffices.) ## What changes downstream For `aiolabs/lamassu-next` (the surfaced consumer): the cash-in flow against a regtest LNbits running plain HTTP on a LAN IP now works end-to-end without TLS termination. Customer phone wallet on the same LAN scans the QR, dereferences the HTTP callback, redeems. Production with HTTPS LNbits: unchanged. `_is_private_network_http` returns `False`, the validated `lnurl_encode` path runs. Production with HTTP + public IP/hostname: still rejected. The detector returns `False` for public IPs, `lnurl_encode` raises, `_populate_lnurl` swallows, `link.lnurl` stays `None`, consumers fail loudly. The misconfig case stays caught. ## Why not the other shapes - **Bypass validation entirely** ("admin knows what they're doing") — they don't, that's the whole point of having the validation. Rejected upstream and rejected here. - **Add an admin-facing `lnbits_allow_local_http_lnurl` flag** — explicit opt-in is clearer than the implicit-IP-detector, but introduces a new audit surface that can be turned on by mistake in production. The RFC1918 rule self-documents. - **Push the dev workaround back to the consumer (bitspire-side)** — re-introduces the `VITE_LNBITS_HTTP_URL` env var that aiolabs/withdraw#1 / `e9d911e` just deleted. Defeats the source-of-truth alignment. ## Tests - `test_create_lnurl_from_baseurl_http_localhost_succeeds` - `test_create_lnurl_from_baseurl_http_127_0_0_1_succeeds` - `test_create_lnurl_from_baseurl_http_rfc1918_succeeds` (parametrized over `10.0.0.5`, `172.16.0.5`, `192.168.0.5`) - `test_create_lnurl_from_baseurl_http_public_ip_raises_value_error` - `test_create_lnurl_from_baseurl_https_public_succeeds` - `test_create_lnurl_from_baseurl_http_unresolvable_hostname_raises` ## Cross-refs - Parent: aiolabs/withdraw#1 (`e9d911e`) — the original nostr-transport `link.lnurl=null` fix - Surfaced in cross-session coordination log 2026-06-01 evening — gap-2 smoke against the regtest setup at `http://192.168.0.32:5001` showed the python-lnurl validation rejecting the LAN-IP HTTP URL - Bitspire consumer that triggered the discovery: `aiolabs/lamassu-next:apps/machine/src/services/lightning.ts:692-707` (the new `link.lnurl` consumption path)
Author
Owner

Closing as won't-fix — going with TLS termination (self-signed cert) on the dev LNbits instead.

The carve-out shipped as 66026ab + 40dce4d got the producer side generating LAN-IP HTTP LNURLs, but the symmetric problem on the consumer side (lnbits-core /api/v1/lnurlscanlnurl.handle()Lnurl(...)valid_lnurl_host rejects non-HTTPS non-localhost) meant LNbits couldn't redeem its own output. Carving out the consumer side too would require an lnbits-core fork patch on _handle + check_callback_url, and at that point the diff is wider than just running TLS.

Reverted in:

  • 2877cf6 — Revert "fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)"
  • 0e06ab2 — Revert "fix: extend RFC1918 LNURL carve-out to the HTTP-views path"

The fix for #1 (e9d911e, populate lnurl/lnurl_url from settings.lnbits_baseurl in the nostr-transport handlers) is kept — it's orthogonal to the transport scheme and works the same whether LNbits is on HTTP or HTTPS.

Closing as won't-fix — going with TLS termination (self-signed cert) on the dev LNbits instead. The carve-out shipped as `66026ab` + `40dce4d` got the producer side generating LAN-IP HTTP LNURLs, but the symmetric problem on the consumer side (lnbits-core `/api/v1/lnurlscan` → `lnurl.handle()` → `Lnurl(...)` → `valid_lnurl_host` rejects non-HTTPS non-localhost) meant LNbits couldn't redeem its own output. Carving out the consumer side too would require an lnbits-core fork patch on `_handle` + `check_callback_url`, and at that point the diff is wider than just running TLS. Reverted in: - `2877cf6` — Revert "fix: allow HTTP LNURL for RFC1918/loopback baseurls (#2)" - `0e06ab2` — Revert "fix: extend RFC1918 LNURL carve-out to the HTTP-views path" The fix for #1 (`e9d911e`, populate `lnurl`/`lnurl_url` from `settings.lnbits_baseurl` in the nostr-transport handlers) is kept — it's orthogonal to the transport scheme and works the same whether LNbits is on HTTP or HTTPS.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/withdraw#2
No description provided.