From 12b3a106145d57480817376a6630dc5eb88a7731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 24 Aug 2026 16:09:02 +0200 Subject: [PATCH 1/8] fix: validate and restrict LNURL request targets LNURL endpoints are supplied by the visited website but fetched from a privileged context with broad host permissions. Restrict those fetches: - require https (http only for .onion), and reject loopback, private, link-local, CGNAT and cloud-metadata hosts, for the LNURL detail request, the lnurl-auth login request, and the pay/withdraw/channel callbacks - refuse to follow redirects on these requests so a permitted host cannot bounce them to a denied one - return a fixed message when a detail request fails instead of relaying the upstream response text Adds a shared lnurlValidation helper with unit coverage, and bridges Node's global fetch into the jsdom test environment so the fetch adapter (and msw) work under test. --- jest.custom-test-environment.js | 16 +++ src/app/screens/LNURLChannel/index.tsx | 4 +- src/app/screens/LNURLPay/index.tsx | 5 +- src/app/screens/LNURLWithdraw/index.tsx | 16 ++- .../lib/__tests__/lnurlValidation.test.ts | 72 ++++++++++ src/common/lib/lnurl.ts | 28 ++-- src/common/lib/lnurlValidation.ts | 131 ++++++++++++++++++ .../background-script/actions/lnurl/auth.ts | 10 +- 8 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 src/common/lib/__tests__/lnurlValidation.test.ts create mode 100644 src/common/lib/lnurlValidation.ts diff --git a/jest.custom-test-environment.js b/jest.custom-test-environment.js index 4656351f2c..4b4f50fe45 100644 --- a/jest.custom-test-environment.js +++ b/jest.custom-test-environment.js @@ -12,6 +12,22 @@ class CustomEnvironment extends TestEnvironment { }, context ); + + // jsdom does not expose fetch, which axios' fetch adapter (used for LNURL + // requests) relies on. Bridge Node's global fetch into the test realm so + // those requests run and msw can intercept them. + for (const name of [ + "fetch", + "Headers", + "Request", + "Response", + "AbortController", + "AbortSignal", + ]) { + if (this.global[name] === undefined && globalThis[name] !== undefined) { + this.global[name] = globalThis[name]; + } + } } } diff --git a/src/app/screens/LNURLChannel/index.tsx b/src/app/screens/LNURLChannel/index.tsx index 4c61454e78..f86b2cdd32 100644 --- a/src/app/screens/LNURLChannel/index.tsx +++ b/src/app/screens/LNURLChannel/index.tsx @@ -5,6 +5,7 @@ import ContentMessage from "@components/ContentMessage"; import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import axios from "axios"; +import { lnurlGet } from "~/common/lib/lnurlValidation"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -48,13 +49,12 @@ function LNURLChannel() { ); } - const callbackResponse = await axios.get(details.callback, { + const callbackResponse = await lnurlGet(details.callback, { params: { k1: details.k1, remoteid: nodeId, private: privateChannel ? 1 : 0, }, - adapter: "fetch", }); if (axios.isAxiosError(callbackResponse)) { diff --git a/src/app/screens/LNURLPay/index.tsx b/src/app/screens/LNURLPay/index.tsx index c50d40f8e3..4f33a2e160 100644 --- a/src/app/screens/LNURLPay/index.tsx +++ b/src/app/screens/LNURLPay/index.tsx @@ -12,7 +12,7 @@ import { PopiconsChevronLeftLine, PopiconsChevronTopLine, } from "@popicons/react"; -import axios from "axios"; +import { lnurlGet } from "~/common/lib/lnurlValidation"; import React, { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -138,13 +138,12 @@ function LNURLPay() { let response; try { - response = await axios.get( + response = await lnurlGet( details.callback, { params, // https://github.com/fiatjaf/lnurl-rfc/blob/luds/01.md#http-status-codes-and-content-type validateStatus: () => true, - adapter: "fetch", } ); diff --git a/src/app/screens/LNURLWithdraw/index.tsx b/src/app/screens/LNURLWithdraw/index.tsx index 3deebfb9ab..b83065b5fc 100644 --- a/src/app/screens/LNURLWithdraw/index.tsx +++ b/src/app/screens/LNURLWithdraw/index.tsx @@ -6,6 +6,7 @@ import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import DualCurrencyField from "@components/form/DualCurrencyField"; import axios from "axios"; +import { lnurlGet } from "~/common/lib/lnurlValidation"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -61,12 +62,15 @@ function LNURLWithdraw() { memo: details.defaultDescription, }); - const response = await axios.get(details.callback, { - params: { - k1: details.k1, - pr: invoice.paymentRequest, - }, - }); + const response = await lnurlGet<{ status: string; reason?: string }>( + details.callback, + { + params: { + k1: details.k1, + pr: invoice.paymentRequest, + }, + } + ); if (response.data.status.toUpperCase() === "OK") { setSuccessMessage( diff --git a/src/common/lib/__tests__/lnurlValidation.test.ts b/src/common/lib/__tests__/lnurlValidation.test.ts new file mode 100644 index 0000000000..a6f73938b8 --- /dev/null +++ b/src/common/lib/__tests__/lnurlValidation.test.ts @@ -0,0 +1,72 @@ +import { + assertAllowedLnurlUrl, + isDisallowedLnurlHost, +} from "../lnurlValidation"; + +describe("isDisallowedLnurlHost", () => { + const blocked = [ + "localhost", + "foo.local", + "service.internal", + "app.localhost", + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.5.5", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", // cloud metadata + "100.64.0.1", // CGNAT + "0.0.0.0", + "::1", + "fe80::1", + "fd00::1", + "::ffff:127.0.0.1", + "224.0.0.1", // multicast + "255.255.255.255", // broadcast + "198.18.0.1", // benchmarking + ]; + const allowed = [ + "getalby.com", + "walletofsatoshi.com", + "8.8.8.8", + "172.32.0.1", // just outside RFC1918 + "192.169.0.1", + "example.onion", + ]; + + it.each(blocked)("blocks %s", (host) => { + expect(isDisallowedLnurlHost(host)).toBe(true); + }); + it.each(allowed)("allows %s", (host) => { + expect(isDisallowedLnurlHost(host)).toBe(false); + }); +}); + +describe("assertAllowedLnurlUrl", () => { + it("accepts https public endpoints", () => { + expect( + assertAllowedLnurlUrl("https://getalby.com/.well-known/lnurlp/x").host + ).toBe("getalby.com"); + }); + it("rejects http for public hosts", () => { + expect(() => assertAllowedLnurlUrl("http://getalby.com/x")).toThrow( + /only https/ + ); + }); + it("allows http only for .onion", () => { + expect(assertAllowedLnurlUrl("http://abc.onion/x").protocol).toBe("http:"); + }); + it("rejects loopback and private targets", () => { + expect(() => assertAllowedLnurlUrl("http://127.0.0.1:1234/x")).toThrow(); + expect(() => assertAllowedLnurlUrl("https://127.0.0.1/x")).toThrow( + /not allowed/ + ); + expect(() => + assertAllowedLnurlUrl("https://169.254.169.254/latest") + ).toThrow(/not allowed/); + expect(() => assertAllowedLnurlUrl("https://[::1]/x")).toThrow( + /not allowed/ + ); + }); +}); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index 63e11de8c9..eb35ba09aa 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -9,6 +9,7 @@ import { } from "~/types"; import { bech32Decode } from "../utils/helpers"; +import { assertAllowedLnurlUrl, lnurlGet } from "./lnurlValidation"; const fromInternetIdentifier = (address: string) => { // email regex: https://emailregex.com/ @@ -69,7 +70,7 @@ const lnurl = { }, async getDetails(lnurlString: string): Promise { - const url = normalizeLnurl(lnurlString); + const url = assertAllowedLnurlUrl(normalizeLnurl(lnurlString)); const searchParamsTag = url.searchParams.get("tag"); const searchParamsK1 = url.searchParams.get("k1"); const searchParamsAction = url.searchParams.get("action"); @@ -86,12 +87,9 @@ const lnurl = { return lnurlAuthDetails; } else { try { - const { data }: { data: LNURLDetails | LNURLError } = await axios.get( - url.toString(), - { - adapter: "fetch", - } - ); + const { data }: { data: LNURLDetails | LNURLError } = await lnurlGet< + LNURLDetails | LNURLError + >(url); const lnurlDetails = data; @@ -104,15 +102,15 @@ const lnurl = { return lnurlDetails; } catch (e) { - let error = ""; - if (axios.isAxiosError(e)) { + let error = "Failed to load LNURL details"; + if (this.isLightningAddress(lnurlString)) { error = - (e.response?.data as { reason?: string })?.reason || e.message; - - if (this.isLightningAddress(lnurlString)) { - error = `This is not a valid lightning address. Either the address is invalid or it is using a different and unsupported protocol: ${error}`; - } - } else if (e instanceof Error) { + "This is not a valid lightning address. Either the address is invalid or it is using a different and unsupported protocol."; + } else if ( + !axios.isAxiosError(e) && + e instanceof Error && + e.message.startsWith("Invalid LNURL") + ) { error = e.message; } diff --git a/src/common/lib/lnurlValidation.ts b/src/common/lib/lnurlValidation.ts new file mode 100644 index 0000000000..8083ceb3d3 --- /dev/null +++ b/src/common/lib/lnurlValidation.ts @@ -0,0 +1,131 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; + +/** + * LNURL endpoints are supplied by the visited website but fetched from a + * privileged context that holds broad host permissions. Restrict which + * targets those fetches may reach so a website cannot point them at the + * user's loopback interface, private network, or cloud metadata endpoints. + */ + +const PRIVATE_IPV4_RANGES: Array<[number, number]> = [ + [ipv4ToInt("0.0.0.0"), ipv4ToInt("0.255.255.255")], // "this" network + [ipv4ToInt("10.0.0.0"), ipv4ToInt("10.255.255.255")], // RFC1918 + [ipv4ToInt("100.64.0.0"), ipv4ToInt("100.127.255.255")], // CGNAT + [ipv4ToInt("127.0.0.0"), ipv4ToInt("127.255.255.255")], // loopback + [ipv4ToInt("169.254.0.0"), ipv4ToInt("169.254.255.255")], // link-local + metadata + [ipv4ToInt("172.16.0.0"), ipv4ToInt("172.31.255.255")], // RFC1918 + [ipv4ToInt("192.168.0.0"), ipv4ToInt("192.168.255.255")], // RFC1918 + [ipv4ToInt("192.0.0.0"), ipv4ToInt("192.0.0.255")], // IETF protocol assignments + [ipv4ToInt("198.18.0.0"), ipv4ToInt("198.19.255.255")], // benchmarking + [ipv4ToInt("224.0.0.0"), ipv4ToInt("239.255.255.255")], // multicast + [ipv4ToInt("240.0.0.0"), ipv4ToInt("255.255.255.255")], // reserved + broadcast +]; + +function ipv4ToInt(ip: string): number { + return ( + ip + .split(".") + .reduce((acc, part) => (acc << 8) + (parseInt(part, 10) & 0xff), 0) >>> 0 + ); +} + +function parseIpv4(hostname: string): number | null { + const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) return null; + const octets = match.slice(1).map((o) => parseInt(o, 10)); + if (octets.some((o) => o > 255)) return null; + return ( + ((octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3]) >>> 0 + ); +} + +function isDisallowedIpv6(hostname: string): boolean { + const ip = hostname.toLowerCase(); + if (!ip.includes(":")) return false; + if (ip === "::1" || ip === "::") return true; // loopback / unspecified + if ( + ip.startsWith("fe80") || + ip.startsWith("fe9") || + ip.startsWith("fea") || + ip.startsWith("feb") + ) + return true; // link-local fe80::/10 + if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // unique local fc00::/7 + // IPv4-mapped / -embedded (::ffff:127.0.0.1, ::ffff:a.b.c.d) + const embedded = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (embedded) { + const asInt = parseIpv4(embedded[1]); + if ( + asInt !== null && + PRIVATE_IPV4_RANGES.some(([lo, hi]) => asInt >= lo && asInt <= hi) + ) + return true; + } + return false; +} + +const DISALLOWED_HOST_SUFFIXES = [ + ".local", + ".internal", + ".localhost", + ".home.arpa", +]; + +export function isDisallowedLnurlHost(hostname: string): boolean { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!host) return true; + if (host === "localhost") return true; + if (DISALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) + return true; + + const ipv4 = parseIpv4(host); + if (ipv4 !== null) { + return PRIVATE_IPV4_RANGES.some(([lo, hi]) => ipv4 >= lo && ipv4 <= hi); + } + if (isDisallowedIpv6(host)) return true; + + return false; +} + +/** + * Validate an LNURL fetch target. Requires https, except .onion hosts which may + * use http (they resolve through Tor, never to a local address). Rejects + * loopback, private, link-local and metadata targets. Returns the parsed URL. + */ +export function assertAllowedLnurlUrl(rawUrl: string | URL): URL { + const url = rawUrl instanceof URL ? rawUrl : new URL(rawUrl); + const isOnion = url.hostname + .toLowerCase() + .replace(/\.$/, "") + .endsWith(".onion"); + + if (url.protocol !== "https:" && !(url.protocol === "http:" && isOnion)) { + throw new Error("Invalid LNURL: only https:// endpoints are allowed"); + } + if (!isOnion && isDisallowedLnurlHost(url.hostname)) { + throw new Error("Invalid LNURL: endpoint host is not allowed"); + } + return url; +} + +/** + * axios GET guarded for LNURL: validates the target and refuses to follow + * redirects, so a permitted host cannot bounce the request to a denied one. + */ +export async function lnurlGet( + target: string | URL, + config: AxiosRequestConfig = {} +): Promise> { + const url = assertAllowedLnurlUrl(target); + return axios.get(url.toString(), { + ...config, + adapter: "fetch", + // the fetch adapter honours fetchOptions.redirect; maxRedirects covers the + // xhr/http adapters should the adapter ever change. + maxRedirects: 0, + fetchOptions: { ...(config.fetchOptions || {}), redirect: "error" }, + }); +} diff --git a/src/extension/background-script/actions/lnurl/auth.ts b/src/extension/background-script/actions/lnurl/auth.ts index 45e7e33b7d..bdfc7497c1 100644 --- a/src/extension/background-script/actions/lnurl/auth.ts +++ b/src/extension/background-script/actions/lnurl/auth.ts @@ -1,5 +1,6 @@ import * as secp256k1 from "@noble/secp256k1"; import axios from "axios"; +import { assertAllowedLnurlUrl, lnurlGet } from "~/common/lib/lnurlValidation"; import { Buffer } from "buffer"; import Hex from "crypto-js/enc-hex"; import Utf8 from "crypto-js/enc-utf8"; @@ -42,7 +43,7 @@ export async function authFunction({ throw new Error("LNURL-AUTH FAIL: no account selected"); } - const url = new URL(lnurlDetails.url); + const url = assertAllowedLnurlUrl(lnurlDetails.url); if (!url.host) { throw new Error("Invalid input"); } @@ -116,12 +117,7 @@ export async function authFunction({ loginURL.searchParams.set("t", Date.now().toString()); try { - const authResponse = await axios.get( - loginURL.toString(), - { - adapter: "fetch", - } - ); + const authResponse = await lnurlGet(loginURL); // if the service returned with a HTTP 200 we still check if the response data is OK if (authResponse?.data.status?.toUpperCase() !== "OK") { From eaf4d1ff3b6ce5c2b2cc7aabf94e9fdc4a283506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 24 Aug 2026 16:20:36 +0200 Subject: [PATCH 2/8] fix: harden LNURL host validation for IPv6 and keep service error text - expand IPv6 literals before classifying them, so IPv4-mapped (::ffff:127.0.0.1), IPv4-compatible and NAT64 (64:ff9b::) forms are recognised after the URL parser re-serialises them as hex, and add site-local fec0::/10 - assert the blocked hosts through the URL parser in the tests, since that is the form the validator actually receives - keep the LNURL service's own error text (LUD-06 status: "ERROR") now that the endpoint host is validated, and drop the message that claimed a lightning address was invalid when the server was merely unreachable - take the abort primitives from the same realm as fetch in the test environment --- jest.custom-test-environment.js | 17 ++-- .../lib/__tests__/lnurlValidation.test.ts | 40 ++++++--- src/common/lib/lnurl.ts | 20 +++-- src/common/lib/lnurlValidation.ts | 83 +++++++++++++++---- 4 files changed, 116 insertions(+), 44 deletions(-) diff --git a/jest.custom-test-environment.js b/jest.custom-test-environment.js index 4b4f50fe45..1c6d63c73e 100644 --- a/jest.custom-test-environment.js +++ b/jest.custom-test-environment.js @@ -16,18 +16,19 @@ class CustomEnvironment extends TestEnvironment { // jsdom does not expose fetch, which axios' fetch adapter (used for LNURL // requests) relies on. Bridge Node's global fetch into the test realm so // those requests run and msw can intercept them. - for (const name of [ - "fetch", - "Headers", - "Request", - "Response", - "AbortController", - "AbortSignal", - ]) { + // These must come from the same realm: axios composes an AbortSignal for + // timeouts/cancellation and Node's Request rejects a jsdom one, so the + // abort primitives are replaced rather than only filled in when missing. + for (const name of ["fetch", "Headers", "Request", "Response"]) { if (this.global[name] === undefined && globalThis[name] !== undefined) { this.global[name] = globalThis[name]; } } + for (const name of ["AbortController", "AbortSignal"]) { + if (globalThis[name] !== undefined) { + this.global[name] = globalThis[name]; + } + } } } diff --git a/src/common/lib/__tests__/lnurlValidation.test.ts b/src/common/lib/__tests__/lnurlValidation.test.ts index a6f73938b8..107cde4780 100644 --- a/src/common/lib/__tests__/lnurlValidation.test.ts +++ b/src/common/lib/__tests__/lnurlValidation.test.ts @@ -18,10 +18,6 @@ describe("isDisallowedLnurlHost", () => { "169.254.169.254", // cloud metadata "100.64.0.1", // CGNAT "0.0.0.0", - "::1", - "fe80::1", - "fd00::1", - "::ffff:127.0.0.1", "224.0.0.1", // multicast "255.255.255.255", // broadcast "198.18.0.1", // benchmarking @@ -57,16 +53,34 @@ describe("assertAllowedLnurlUrl", () => { it("allows http only for .onion", () => { expect(assertAllowedLnurlUrl("http://abc.onion/x").protocol).toBe("http:"); }); - it("rejects loopback and private targets", () => { + + // These go through the WHATWG URL parser on purpose: it re-serialises IPv6 + // literals (https://[::ffff:127.0.0.1]/ becomes [::ffff:7f00:1]), so asserting + // on the raw string form would not exercise what the validator actually sees. + const blockedUrls = [ + "https://127.0.0.1/x", + "https://169.254.169.254/latest", + "https://[::1]/x", + "https://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback + "https://[::ffff:169.254.169.254]/", // IPv4-mapped metadata + "https://[::ffff:10.0.0.1]/", // IPv4-mapped RFC1918 + "https://[64:ff9b::127.0.0.1]/", // NAT64 to loopback + "https://[fe80::1]/", // link-local + "https://[fd00::1]/", // unique local + "https://[fec0::1]/", // site-local + "https://[::]/", + ]; + it.each(blockedUrls)("rejects %s", (url) => { + expect(() => assertAllowedLnurlUrl(url)).toThrow(/not allowed/); + }); + + it("rejects http loopback on the scheme check", () => { expect(() => assertAllowedLnurlUrl("http://127.0.0.1:1234/x")).toThrow(); - expect(() => assertAllowedLnurlUrl("https://127.0.0.1/x")).toThrow( - /not allowed/ - ); - expect(() => - assertAllowedLnurlUrl("https://169.254.169.254/latest") - ).toThrow(/not allowed/); - expect(() => assertAllowedLnurlUrl("https://[::1]/x")).toThrow( - /not allowed/ + }); + + it("still allows public IPv6", () => { + expect(assertAllowedLnurlUrl("https://[2606:4700::1111]/x").protocol).toBe( + "https:" ); }); }); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index eb35ba09aa..89e0bd9671 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -11,6 +11,9 @@ import { import { bech32Decode } from "../utils/helpers"; import { assertAllowedLnurlUrl, lnurlGet } from "./lnurlValidation"; +/** An error returned by the LNURL service itself (LUD-06 `status: "ERROR"`). */ +class LNURLServiceError extends Error {} + const fromInternetIdentifier = (address: string) => { // email regex: https://emailregex.com/ // modified to allow _ in subdomains @@ -94,7 +97,7 @@ const lnurl = { const lnurlDetails = data; if (isLNURLDetailsError(lnurlDetails)) { - throw new Error(lnurlDetails.reason); + throw new LNURLServiceError(lnurlDetails.reason); } else { lnurlDetails.domain = url.hostname; lnurlDetails.url = url.toString(); @@ -102,16 +105,23 @@ const lnurl = { return lnurlDetails; } catch (e) { - let error = "Failed to load LNURL details"; - if (this.isLightningAddress(lnurlString)) { - error = - "This is not a valid lightning address. Either the address is invalid or it is using a different and unsupported protocol."; + // The service's own error text is safe to surface: the endpoint host has + // already been validated. Transport failures are reported generically so + // the response of an arbitrary endpoint is not relayed back to a caller. + let error: string; + if (e instanceof LNURLServiceError) { + error = e.message; } else if ( !axios.isAxiosError(e) && e instanceof Error && e.message.startsWith("Invalid LNURL") ) { error = e.message; + } else if (this.isLightningAddress(lnurlString)) { + error = + "Could not reach this lightning address. It may be invalid, or its server may be unavailable."; + } else { + error = "Failed to load LNURL details"; } throw new Error(error); diff --git a/src/common/lib/lnurlValidation.ts b/src/common/lib/lnurlValidation.ts index 8083ceb3d3..8778bb0a5f 100644 --- a/src/common/lib/lnurlValidation.ts +++ b/src/common/lib/lnurlValidation.ts @@ -39,27 +39,74 @@ function parseIpv4(hostname: string): number | null { ); } +/** + * Expand an IPv6 literal (any "::" form, optionally ending in a dotted quad) + * into its eight 16-bit groups. Returns null if it is not an IPv6 address. + */ +function expandIpv6(ip: string): number[] | null { + if (!ip.includes(":")) return null; + + let text = ip; + // a trailing dotted quad (::ffff:127.0.0.1) becomes two hex groups + const dotted = text.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + const asInt = parseIpv4(dotted[1]); + if (asInt === null) return null; + text = + text.slice(0, dotted.index) + + ((asInt >>> 16) & 0xffff).toString(16) + + ":" + + (asInt & 0xffff).toString(16); + } + + const halves = text.split("::"); + if (halves.length > 2) return null; + + const parse = (part: string) => + part === "" ? [] : part.split(":").map((g) => parseInt(g, 16)); + + const head = parse(halves[0]); + const tail = halves.length === 2 ? parse(halves[1]) : []; + if ([...head, ...tail].some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) { + return null; + } + + let groups: number[]; + if (halves.length === 2) { + const fill = 8 - head.length - tail.length; + if (fill < 0) return null; + groups = [...head, ...new Array(fill).fill(0), ...tail]; + } else { + groups = head; + } + return groups.length === 8 ? groups : null; +} + function isDisallowedIpv6(hostname: string): boolean { - const ip = hostname.toLowerCase(); - if (!ip.includes(":")) return false; - if (ip === "::1" || ip === "::") return true; // loopback / unspecified - if ( - ip.startsWith("fe80") || - ip.startsWith("fe9") || - ip.startsWith("fea") || - ip.startsWith("feb") - ) - return true; // link-local fe80::/10 - if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // unique local fc00::/7 - // IPv4-mapped / -embedded (::ffff:127.0.0.1, ::ffff:a.b.c.d) - const embedded = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); - if (embedded) { - const asInt = parseIpv4(embedded[1]); + const groups = expandIpv6(hostname.toLowerCase()); + if (!groups) return false; + + const isZero = (upTo: number) => groups.slice(0, upTo).every((g) => g === 0); + + // :: (unspecified) and ::1 (loopback) + if (isZero(7) && (groups[7] === 0 || groups[7] === 1)) return true; + // fe80::/10 link-local, fc00::/7 unique local, fec0::/10 site-local + if ((groups[0] & 0xffc0) === 0xfe80) return true; + if ((groups[0] & 0xfe00) === 0xfc00) return true; + if ((groups[0] & 0xffc0) === 0xfec0) return true; + + // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d) and + // NAT64 (64:ff9b::a.b.c.d) all carry an IPv4 address in the last two groups. + const isMapped = isZero(5) && groups[5] === 0xffff; + const isCompatible = isZero(6); + const isNat64 = groups[0] === 0x0064 && groups[1] === 0xff9b; + if (isMapped || isCompatible || isNat64) { + const embedded = ((groups[6] << 16) + groups[7]) >>> 0; if ( - asInt !== null && - PRIVATE_IPV4_RANGES.some(([lo, hi]) => asInt >= lo && asInt <= hi) - ) + PRIVATE_IPV4_RANGES.some(([lo, hi]) => embedded >= lo && embedded <= hi) + ) { return true; + } } return false; } From fb0deb8d666fe12c80166a0d705b1746ee2280bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 24 Aug 2026 16:24:59 +0200 Subject: [PATCH 3/8] fix: scope LNURL endpoint restrictions to website-supplied LNURLs The restrictions are there because a website can hand the extension an endpoint that is then requested from a privileged context. An LNURL the user pasted or scanned themselves is a target they chose, and may well be a self-hosted service on a local network over http. - getDetails takes a userInitiated flag; Send and LNURLRedeem set it, so those keep working against a local service and may follow redirects. The website-driven paths (webln.lnurl, PublisherLnData) are unchanged. - callbacks are allowed on the origin of the LNURL that produced them, and otherwise have to satisfy the usual restrictions, so a self-hosted service's own callback works while a cross-host callback still cannot point at a private address. --- src/app/screens/LNURLChannel/index.tsx | 22 ++++++++----- src/app/screens/LNURLPay/index.tsx | 7 ++-- src/app/screens/LNURLRedeem/index.tsx | 4 ++- src/app/screens/LNURLWithdraw/index.tsx | 7 ++-- src/app/screens/Send/index.tsx | 4 ++- .../lib/__tests__/lnurlValidation.test.ts | 30 +++++++++++++++++ src/common/lib/lnurl.ts | 20 ++++++++++-- src/common/lib/lnurlValidation.ts | 32 +++++++++++++++++-- 8 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/app/screens/LNURLChannel/index.tsx b/src/app/screens/LNURLChannel/index.tsx index f86b2cdd32..15972acca7 100644 --- a/src/app/screens/LNURLChannel/index.tsx +++ b/src/app/screens/LNURLChannel/index.tsx @@ -5,7 +5,10 @@ import ContentMessage from "@components/ContentMessage"; import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import axios from "axios"; -import { lnurlGet } from "~/common/lib/lnurlValidation"; +import { + assertAllowedCallbackUrl, + lnurlGet, +} from "~/common/lib/lnurlValidation"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -49,13 +52,16 @@ function LNURLChannel() { ); } - const callbackResponse = await lnurlGet(details.callback, { - params: { - k1: details.k1, - remoteid: nodeId, - private: privateChannel ? 1 : 0, - }, - }); + const callbackResponse = await lnurlGet( + assertAllowedCallbackUrl(details.callback, details.url), + { + params: { + k1: details.k1, + remoteid: nodeId, + private: privateChannel ? 1 : 0, + }, + } + ); if (axios.isAxiosError(callbackResponse)) { toast.error(`Failed to call callback: ${callbackResponse.message}`); diff --git a/src/app/screens/LNURLPay/index.tsx b/src/app/screens/LNURLPay/index.tsx index 4f33a2e160..8df8164aa2 100644 --- a/src/app/screens/LNURLPay/index.tsx +++ b/src/app/screens/LNURLPay/index.tsx @@ -12,7 +12,10 @@ import { PopiconsChevronLeftLine, PopiconsChevronTopLine, } from "@popicons/react"; -import { lnurlGet } from "~/common/lib/lnurlValidation"; +import { + assertAllowedCallbackUrl, + lnurlGet, +} from "~/common/lib/lnurlValidation"; import React, { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -139,7 +142,7 @@ function LNURLPay() { try { response = await lnurlGet( - details.callback, + assertAllowedCallbackUrl(details.callback, details.url), { params, // https://github.com/fiatjaf/lnurl-rfc/blob/luds/01.md#http-status-codes-and-content-type diff --git a/src/app/screens/LNURLRedeem/index.tsx b/src/app/screens/LNURLRedeem/index.tsx index fbda28bd5f..e9f8a29c46 100644 --- a/src/app/screens/LNURLRedeem/index.tsx +++ b/src/app/screens/LNURLRedeem/index.tsx @@ -30,7 +30,9 @@ function LNURLRedeem() { const lnurl = lnurlLib.findLnurl(lnurlWithdrawLink); if (lnurl) { - const lnurlDetails = await lnurlLib.getDetails(lnurl); + const lnurlDetails = await lnurlLib.getDetails(lnurl, { + userInitiated: true, + }); if (isLNURLDetailsError(lnurlDetails)) { toast.error(lnurlDetails.reason); diff --git a/src/app/screens/LNURLWithdraw/index.tsx b/src/app/screens/LNURLWithdraw/index.tsx index b83065b5fc..9d186e930d 100644 --- a/src/app/screens/LNURLWithdraw/index.tsx +++ b/src/app/screens/LNURLWithdraw/index.tsx @@ -6,7 +6,10 @@ import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import DualCurrencyField from "@components/form/DualCurrencyField"; import axios from "axios"; -import { lnurlGet } from "~/common/lib/lnurlValidation"; +import { + assertAllowedCallbackUrl, + lnurlGet, +} from "~/common/lib/lnurlValidation"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -63,7 +66,7 @@ function LNURLWithdraw() { }); const response = await lnurlGet<{ status: string; reason?: string }>( - details.callback, + assertAllowedCallbackUrl(details.callback, details.url), { params: { k1: details.k1, diff --git a/src/app/screens/Send/index.tsx b/src/app/screens/Send/index.tsx index a20c43a520..b97a90b59b 100644 --- a/src/app/screens/Send/index.tsx +++ b/src/app/screens/Send/index.tsx @@ -60,7 +60,9 @@ function Send() { } if (lnurl) { - const lnurlDetails = await lnurlLib.getDetails(lnurl); + const lnurlDetails = await lnurlLib.getDetails(lnurl, { + userInitiated: true, + }); if (isLNURLDetailsError(lnurlDetails)) { toast.error(lnurlDetails.reason); return; diff --git a/src/common/lib/__tests__/lnurlValidation.test.ts b/src/common/lib/__tests__/lnurlValidation.test.ts index 107cde4780..306019e23e 100644 --- a/src/common/lib/__tests__/lnurlValidation.test.ts +++ b/src/common/lib/__tests__/lnurlValidation.test.ts @@ -1,4 +1,5 @@ import { + assertAllowedCallbackUrl, assertAllowedLnurlUrl, isDisallowedLnurlHost, } from "../lnurlValidation"; @@ -84,3 +85,32 @@ describe("assertAllowedLnurlUrl", () => { ); }); }); + +describe("assertAllowedCallbackUrl", () => { + it("allows a cross-host callback that is itself public (lightning address)", () => { + expect( + assertAllowedCallbackUrl( + "https://callback.example.com/pay", + "https://getalby.com/.well-known/lnurlp/x" + ).host + ).toBe("callback.example.com"); + }); + + it("allows a callback on the same origin as the LNURL, even on a local network", () => { + expect( + assertAllowedCallbackUrl( + "http://192.168.1.5:5000/withdraw/api/v1/lnurl/cb/abc", + "http://192.168.1.5:5000/withdraw/api/v1/lnurl/abc" + ).host + ).toBe("192.168.1.5:5000"); + }); + + it("rejects a cross-host callback pointing at a private address", () => { + expect(() => + assertAllowedCallbackUrl( + "http://127.0.0.1:8443/internal", + "https://getalby.com/.well-known/lnurlp/x" + ) + ).toThrow(); + }); +}); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index 89e0bd9671..a78d31fe2c 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -72,8 +72,18 @@ const lnurl = { return null; }, - async getDetails(lnurlString: string): Promise { - const url = assertAllowedLnurlUrl(normalizeLnurl(lnurlString)); + /** + * `userInitiated` marks an LNURL the user pasted or scanned themselves. Those + * may point at a self-hosted service on a local network; LNURLs supplied by a + * website may not, since the request is made from a privileged context. + */ + async getDetails( + lnurlString: string, + { userInitiated = false } = {} + ): Promise { + const url = userInitiated + ? normalizeLnurl(lnurlString) + : assertAllowedLnurlUrl(normalizeLnurl(lnurlString)); const searchParamsTag = url.searchParams.get("tag"); const searchParamsK1 = url.searchParams.get("k1"); const searchParamsAction = url.searchParams.get("action"); @@ -92,7 +102,11 @@ const lnurl = { try { const { data }: { data: LNURLDetails | LNURLError } = await lnurlGet< LNURLDetails | LNURLError - >(url); + >( + url, + {}, + { validate: !userInitiated, followRedirects: userInitiated } + ); const lnurlDetails = data; diff --git a/src/common/lib/lnurlValidation.ts b/src/common/lib/lnurlValidation.ts index 8778bb0a5f..dfa1f6cb8c 100644 --- a/src/common/lib/lnurlValidation.ts +++ b/src/common/lib/lnurlValidation.ts @@ -158,15 +158,43 @@ export function assertAllowedLnurlUrl(rawUrl: string | URL): URL { return url; } +/** + * A callback may stay on the origin of the LNURL that produced it whatever that + * origin was (this keeps self-hosted services on a local network working); + * otherwise it has to satisfy the normal restrictions. Cross-host callbacks are + * common for lightning addresses, so the origin is not required to match. + */ +export function assertAllowedCallbackUrl( + callback: string | URL, + lnurlUrl: string | URL | undefined +): URL { + const callbackUrl = callback instanceof URL ? callback : new URL(callback); + if (lnurlUrl) { + const base = lnurlUrl instanceof URL ? lnurlUrl : new URL(lnurlUrl); + if (callbackUrl.origin === base.origin) { + return callbackUrl; + } + } + return assertAllowedLnurlUrl(callbackUrl); +} + /** * axios GET guarded for LNURL: validates the target and refuses to follow * redirects, so a permitted host cannot bounce the request to a denied one. */ export async function lnurlGet( target: string | URL, - config: AxiosRequestConfig = {} + config: AxiosRequestConfig = {}, + { validate = true, followRedirects = false } = {} ): Promise> { - const url = assertAllowedLnurlUrl(target); + const url = validate + ? assertAllowedLnurlUrl(target) + : target instanceof URL + ? target + : new URL(target); + if (followRedirects) { + return axios.get(url.toString(), { ...config, adapter: "fetch" }); + } return axios.get(url.toString(), { ...config, adapter: "fetch", From 178fb2cd2578ef23a1fabbdcdfb8ee016e5d2d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 8 Sep 2026 22:36:15 +0200 Subject: [PATCH 4/8] refactor: classify LNURL hosts with ipaddr.js instead of a hand-rolled parser Replaces the custom IPv4 range table and IPv6 expansion with an allow-list on ipaddr.process(host).range() === "unicast". ipaddr.js unwraps IPv4-mapped IPv6 itself and names every range the validator denied (loopback, private, link-local, CGNAT, NAT64, unique-local, deprecated site-local, multicast, reserved). Requires 2.5.0, which added the fec0::/10 site-local range. Claude-Session: https://claude.ai/code/session_01KsUBBAqgYc37oxJHibvR4u --- package.json | 1 + src/common/lib/lnurlValidation.ts | 115 ++---------------------------- yarn.lock | 5 ++ 3 files changed, 13 insertions(+), 108 deletions(-) diff --git a/package.json b/package.json index 69857b7679..a5214ee47e 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "html5-qrcode": "^2.3.8", "i18next-browser-languagedetector": "^8.2.1", "i18next": "^25.10.10", + "ipaddr.js": "^2.5.0", "liquidjs-lib": "^6.0.2-liquid.29", "lodash.merge": "^4.6.2", "lodash.pick": "^4.4.0", diff --git a/src/common/lib/lnurlValidation.ts b/src/common/lib/lnurlValidation.ts index dfa1f6cb8c..fa070a47eb 100644 --- a/src/common/lib/lnurlValidation.ts +++ b/src/common/lib/lnurlValidation.ts @@ -1,4 +1,5 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import ipaddr from "ipaddr.js"; /** * LNURL endpoints are supplied by the visited website but fetched from a @@ -7,110 +8,6 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; * user's loopback interface, private network, or cloud metadata endpoints. */ -const PRIVATE_IPV4_RANGES: Array<[number, number]> = [ - [ipv4ToInt("0.0.0.0"), ipv4ToInt("0.255.255.255")], // "this" network - [ipv4ToInt("10.0.0.0"), ipv4ToInt("10.255.255.255")], // RFC1918 - [ipv4ToInt("100.64.0.0"), ipv4ToInt("100.127.255.255")], // CGNAT - [ipv4ToInt("127.0.0.0"), ipv4ToInt("127.255.255.255")], // loopback - [ipv4ToInt("169.254.0.0"), ipv4ToInt("169.254.255.255")], // link-local + metadata - [ipv4ToInt("172.16.0.0"), ipv4ToInt("172.31.255.255")], // RFC1918 - [ipv4ToInt("192.168.0.0"), ipv4ToInt("192.168.255.255")], // RFC1918 - [ipv4ToInt("192.0.0.0"), ipv4ToInt("192.0.0.255")], // IETF protocol assignments - [ipv4ToInt("198.18.0.0"), ipv4ToInt("198.19.255.255")], // benchmarking - [ipv4ToInt("224.0.0.0"), ipv4ToInt("239.255.255.255")], // multicast - [ipv4ToInt("240.0.0.0"), ipv4ToInt("255.255.255.255")], // reserved + broadcast -]; - -function ipv4ToInt(ip: string): number { - return ( - ip - .split(".") - .reduce((acc, part) => (acc << 8) + (parseInt(part, 10) & 0xff), 0) >>> 0 - ); -} - -function parseIpv4(hostname: string): number | null { - const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (!match) return null; - const octets = match.slice(1).map((o) => parseInt(o, 10)); - if (octets.some((o) => o > 255)) return null; - return ( - ((octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3]) >>> 0 - ); -} - -/** - * Expand an IPv6 literal (any "::" form, optionally ending in a dotted quad) - * into its eight 16-bit groups. Returns null if it is not an IPv6 address. - */ -function expandIpv6(ip: string): number[] | null { - if (!ip.includes(":")) return null; - - let text = ip; - // a trailing dotted quad (::ffff:127.0.0.1) becomes two hex groups - const dotted = text.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); - if (dotted) { - const asInt = parseIpv4(dotted[1]); - if (asInt === null) return null; - text = - text.slice(0, dotted.index) + - ((asInt >>> 16) & 0xffff).toString(16) + - ":" + - (asInt & 0xffff).toString(16); - } - - const halves = text.split("::"); - if (halves.length > 2) return null; - - const parse = (part: string) => - part === "" ? [] : part.split(":").map((g) => parseInt(g, 16)); - - const head = parse(halves[0]); - const tail = halves.length === 2 ? parse(halves[1]) : []; - if ([...head, ...tail].some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) { - return null; - } - - let groups: number[]; - if (halves.length === 2) { - const fill = 8 - head.length - tail.length; - if (fill < 0) return null; - groups = [...head, ...new Array(fill).fill(0), ...tail]; - } else { - groups = head; - } - return groups.length === 8 ? groups : null; -} - -function isDisallowedIpv6(hostname: string): boolean { - const groups = expandIpv6(hostname.toLowerCase()); - if (!groups) return false; - - const isZero = (upTo: number) => groups.slice(0, upTo).every((g) => g === 0); - - // :: (unspecified) and ::1 (loopback) - if (isZero(7) && (groups[7] === 0 || groups[7] === 1)) return true; - // fe80::/10 link-local, fc00::/7 unique local, fec0::/10 site-local - if ((groups[0] & 0xffc0) === 0xfe80) return true; - if ((groups[0] & 0xfe00) === 0xfc00) return true; - if ((groups[0] & 0xffc0) === 0xfec0) return true; - - // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d) and - // NAT64 (64:ff9b::a.b.c.d) all carry an IPv4 address in the last two groups. - const isMapped = isZero(5) && groups[5] === 0xffff; - const isCompatible = isZero(6); - const isNat64 = groups[0] === 0x0064 && groups[1] === 0xff9b; - if (isMapped || isCompatible || isNat64) { - const embedded = ((groups[6] << 16) + groups[7]) >>> 0; - if ( - PRIVATE_IPV4_RANGES.some(([lo, hi]) => embedded >= lo && embedded <= hi) - ) { - return true; - } - } - return false; -} - const DISALLOWED_HOST_SUFFIXES = [ ".local", ".internal", @@ -128,11 +25,13 @@ export function isDisallowedLnurlHost(hostname: string): boolean { if (DISALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; - const ipv4 = parseIpv4(host); - if (ipv4 !== null) { - return PRIVATE_IPV4_RANGES.some(([lo, hi]) => ipv4 >= lo && ipv4 <= hi); + if (ipaddr.isValid(host)) { + // process() unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d) to the embedded + // IPv4 address, so it is classified by the IPv4 ranges. Everything that is + // not plain unicast (loopback, private, link-local, CGNAT, NAT64, ULA, + // multicast, reserved, ...) is denied. + return ipaddr.process(host).range() !== "unicast"; } - if (isDisallowedIpv6(host)) return true; return false; } diff --git a/yarn.lock b/yarn.lock index 8453481a1f..e8a60ecec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6690,6 +6690,11 @@ ipaddr.js@^2.1.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz#71dce70e1398122208996d1c22f2ba46a24b1abc" integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg== +ipaddr.js@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.5.0.tgz#7d4b6c39f9392fb61cf807de6e425e22c10e061f" + integrity sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w== + is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz" From f9366a3f90646603c4c69e5df0009636f0e1d037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 8 Sep 2026 23:39:31 +0200 Subject: [PATCH 5/8] refactor: check LNURL targets once, at the website entry point Replaces the per-call validator and the userInitiated split with a single private-host check in the background lnurl action, the only path a website controls. The LNURL itself and the callback returned by the service are both refused when they point at localhost, a private/link-local/CGNAT/NAT64 address, or a .local/.internal/.home.arpa name (classified by ipaddr.js). getDetails now accepts only LNURL-shaped responses and reports every other failure with a generic message, so the content of an arbitrary endpoint can no longer leak into the error returned to a website. User-driven screens (Send, LNURLRedeem, Pay/Withdraw/Channel confirm, lnurl-auth) are back to their previous behaviour: self-hosted http and LAN services work and redirects are followed. Claude-Session: https://claude.ai/code/session_01KsUBBAqgYc37oxJHibvR4u --- src/app/screens/LNURLChannel/index.tsx | 22 ++-- src/app/screens/LNURLPay/index.tsx | 10 +- src/app/screens/LNURLRedeem/index.tsx | 4 +- src/app/screens/LNURLWithdraw/index.tsx | 19 +-- src/app/screens/Send/index.tsx | 4 +- .../lib/__tests__/lnurlValidation.test.ts | 116 ------------------ src/common/lib/lnurl.ts | 61 ++++----- src/common/lib/lnurlValidation.ts | 105 ---------------- .../lnurl/__tests__/isPrivateHost.test.ts | 48 ++++++++ .../background-script/actions/lnurl/auth.ts | 10 +- .../background-script/actions/lnurl/index.ts | 34 +++++ 11 files changed, 141 insertions(+), 292 deletions(-) delete mode 100644 src/common/lib/__tests__/lnurlValidation.test.ts delete mode 100644 src/common/lib/lnurlValidation.ts create mode 100644 src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts diff --git a/src/app/screens/LNURLChannel/index.tsx b/src/app/screens/LNURLChannel/index.tsx index 15972acca7..4c61454e78 100644 --- a/src/app/screens/LNURLChannel/index.tsx +++ b/src/app/screens/LNURLChannel/index.tsx @@ -5,10 +5,6 @@ import ContentMessage from "@components/ContentMessage"; import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import axios from "axios"; -import { - assertAllowedCallbackUrl, - lnurlGet, -} from "~/common/lib/lnurlValidation"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -52,16 +48,14 @@ function LNURLChannel() { ); } - const callbackResponse = await lnurlGet( - assertAllowedCallbackUrl(details.callback, details.url), - { - params: { - k1: details.k1, - remoteid: nodeId, - private: privateChannel ? 1 : 0, - }, - } - ); + const callbackResponse = await axios.get(details.callback, { + params: { + k1: details.k1, + remoteid: nodeId, + private: privateChannel ? 1 : 0, + }, + adapter: "fetch", + }); if (axios.isAxiosError(callbackResponse)) { toast.error(`Failed to call callback: ${callbackResponse.message}`); diff --git a/src/app/screens/LNURLPay/index.tsx b/src/app/screens/LNURLPay/index.tsx index 8df8164aa2..c50d40f8e3 100644 --- a/src/app/screens/LNURLPay/index.tsx +++ b/src/app/screens/LNURLPay/index.tsx @@ -12,10 +12,7 @@ import { PopiconsChevronLeftLine, PopiconsChevronTopLine, } from "@popicons/react"; -import { - assertAllowedCallbackUrl, - lnurlGet, -} from "~/common/lib/lnurlValidation"; +import axios from "axios"; import React, { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -141,12 +138,13 @@ function LNURLPay() { let response; try { - response = await lnurlGet( - assertAllowedCallbackUrl(details.callback, details.url), + response = await axios.get( + details.callback, { params, // https://github.com/fiatjaf/lnurl-rfc/blob/luds/01.md#http-status-codes-and-content-type validateStatus: () => true, + adapter: "fetch", } ); diff --git a/src/app/screens/LNURLRedeem/index.tsx b/src/app/screens/LNURLRedeem/index.tsx index e9f8a29c46..fbda28bd5f 100644 --- a/src/app/screens/LNURLRedeem/index.tsx +++ b/src/app/screens/LNURLRedeem/index.tsx @@ -30,9 +30,7 @@ function LNURLRedeem() { const lnurl = lnurlLib.findLnurl(lnurlWithdrawLink); if (lnurl) { - const lnurlDetails = await lnurlLib.getDetails(lnurl, { - userInitiated: true, - }); + const lnurlDetails = await lnurlLib.getDetails(lnurl); if (isLNURLDetailsError(lnurlDetails)) { toast.error(lnurlDetails.reason); diff --git a/src/app/screens/LNURLWithdraw/index.tsx b/src/app/screens/LNURLWithdraw/index.tsx index 9d186e930d..3deebfb9ab 100644 --- a/src/app/screens/LNURLWithdraw/index.tsx +++ b/src/app/screens/LNURLWithdraw/index.tsx @@ -6,10 +6,6 @@ import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import DualCurrencyField from "@components/form/DualCurrencyField"; import axios from "axios"; -import { - assertAllowedCallbackUrl, - lnurlGet, -} from "~/common/lib/lnurlValidation"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -65,15 +61,12 @@ function LNURLWithdraw() { memo: details.defaultDescription, }); - const response = await lnurlGet<{ status: string; reason?: string }>( - assertAllowedCallbackUrl(details.callback, details.url), - { - params: { - k1: details.k1, - pr: invoice.paymentRequest, - }, - } - ); + const response = await axios.get(details.callback, { + params: { + k1: details.k1, + pr: invoice.paymentRequest, + }, + }); if (response.data.status.toUpperCase() === "OK") { setSuccessMessage( diff --git a/src/app/screens/Send/index.tsx b/src/app/screens/Send/index.tsx index b97a90b59b..a20c43a520 100644 --- a/src/app/screens/Send/index.tsx +++ b/src/app/screens/Send/index.tsx @@ -60,9 +60,7 @@ function Send() { } if (lnurl) { - const lnurlDetails = await lnurlLib.getDetails(lnurl, { - userInitiated: true, - }); + const lnurlDetails = await lnurlLib.getDetails(lnurl); if (isLNURLDetailsError(lnurlDetails)) { toast.error(lnurlDetails.reason); return; diff --git a/src/common/lib/__tests__/lnurlValidation.test.ts b/src/common/lib/__tests__/lnurlValidation.test.ts deleted file mode 100644 index 306019e23e..0000000000 --- a/src/common/lib/__tests__/lnurlValidation.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - assertAllowedCallbackUrl, - assertAllowedLnurlUrl, - isDisallowedLnurlHost, -} from "../lnurlValidation"; - -describe("isDisallowedLnurlHost", () => { - const blocked = [ - "localhost", - "foo.local", - "service.internal", - "app.localhost", - "127.0.0.1", - "127.1.2.3", - "10.0.0.1", - "172.16.5.5", - "172.31.255.255", - "192.168.1.1", - "169.254.169.254", // cloud metadata - "100.64.0.1", // CGNAT - "0.0.0.0", - "224.0.0.1", // multicast - "255.255.255.255", // broadcast - "198.18.0.1", // benchmarking - ]; - const allowed = [ - "getalby.com", - "walletofsatoshi.com", - "8.8.8.8", - "172.32.0.1", // just outside RFC1918 - "192.169.0.1", - "example.onion", - ]; - - it.each(blocked)("blocks %s", (host) => { - expect(isDisallowedLnurlHost(host)).toBe(true); - }); - it.each(allowed)("allows %s", (host) => { - expect(isDisallowedLnurlHost(host)).toBe(false); - }); -}); - -describe("assertAllowedLnurlUrl", () => { - it("accepts https public endpoints", () => { - expect( - assertAllowedLnurlUrl("https://getalby.com/.well-known/lnurlp/x").host - ).toBe("getalby.com"); - }); - it("rejects http for public hosts", () => { - expect(() => assertAllowedLnurlUrl("http://getalby.com/x")).toThrow( - /only https/ - ); - }); - it("allows http only for .onion", () => { - expect(assertAllowedLnurlUrl("http://abc.onion/x").protocol).toBe("http:"); - }); - - // These go through the WHATWG URL parser on purpose: it re-serialises IPv6 - // literals (https://[::ffff:127.0.0.1]/ becomes [::ffff:7f00:1]), so asserting - // on the raw string form would not exercise what the validator actually sees. - const blockedUrls = [ - "https://127.0.0.1/x", - "https://169.254.169.254/latest", - "https://[::1]/x", - "https://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback - "https://[::ffff:169.254.169.254]/", // IPv4-mapped metadata - "https://[::ffff:10.0.0.1]/", // IPv4-mapped RFC1918 - "https://[64:ff9b::127.0.0.1]/", // NAT64 to loopback - "https://[fe80::1]/", // link-local - "https://[fd00::1]/", // unique local - "https://[fec0::1]/", // site-local - "https://[::]/", - ]; - it.each(blockedUrls)("rejects %s", (url) => { - expect(() => assertAllowedLnurlUrl(url)).toThrow(/not allowed/); - }); - - it("rejects http loopback on the scheme check", () => { - expect(() => assertAllowedLnurlUrl("http://127.0.0.1:1234/x")).toThrow(); - }); - - it("still allows public IPv6", () => { - expect(assertAllowedLnurlUrl("https://[2606:4700::1111]/x").protocol).toBe( - "https:" - ); - }); -}); - -describe("assertAllowedCallbackUrl", () => { - it("allows a cross-host callback that is itself public (lightning address)", () => { - expect( - assertAllowedCallbackUrl( - "https://callback.example.com/pay", - "https://getalby.com/.well-known/lnurlp/x" - ).host - ).toBe("callback.example.com"); - }); - - it("allows a callback on the same origin as the LNURL, even on a local network", () => { - expect( - assertAllowedCallbackUrl( - "http://192.168.1.5:5000/withdraw/api/v1/lnurl/cb/abc", - "http://192.168.1.5:5000/withdraw/api/v1/lnurl/abc" - ).host - ).toBe("192.168.1.5:5000"); - }); - - it("rejects a cross-host callback pointing at a private address", () => { - expect(() => - assertAllowedCallbackUrl( - "http://127.0.0.1:8443/internal", - "https://getalby.com/.well-known/lnurlp/x" - ) - ).toThrow(); - }); -}); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index a78d31fe2c..772e3ef608 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -9,11 +9,26 @@ import { } from "~/types"; import { bech32Decode } from "../utils/helpers"; -import { assertAllowedLnurlUrl, lnurlGet } from "./lnurlValidation"; /** An error returned by the LNURL service itself (LUD-06 `status: "ERROR"`). */ class LNURLServiceError extends Error {} +const LNURL_TAGS = ["payRequest", "withdrawRequest", "channelRequest", "login"]; + +/** + * Only a response that looks like an LNURL service response is processed any + * further. Anything else (HTML, plain text, unrelated JSON) is rejected with a + * generic error so its content never leaks into an error message. + */ +const isLNURLResponse = (data: unknown): data is LNURLDetails | LNURLError => { + if (typeof data !== "object" || data === null) return false; + const res = data as Record; + if (res.status === "ERROR") return typeof res.reason === "string"; + if (typeof res.tag !== "string" || !LNURL_TAGS.includes(res.tag)) + return false; + return res.tag === "login" || typeof res.callback === "string"; +}; + const fromInternetIdentifier = (address: string) => { // email regex: https://emailregex.com/ // modified to allow _ in subdomains @@ -72,18 +87,10 @@ const lnurl = { return null; }, - /** - * `userInitiated` marks an LNURL the user pasted or scanned themselves. Those - * may point at a self-hosted service on a local network; LNURLs supplied by a - * website may not, since the request is made from a privileged context. - */ - async getDetails( - lnurlString: string, - { userInitiated = false } = {} - ): Promise { - const url = userInitiated - ? normalizeLnurl(lnurlString) - : assertAllowedLnurlUrl(normalizeLnurl(lnurlString)); + normalizeLnurl, + + async getDetails(lnurlString: string): Promise { + const url = normalizeLnurl(lnurlString); const searchParamsTag = url.searchParams.get("tag"); const searchParamsK1 = url.searchParams.get("k1"); const searchParamsAction = url.searchParams.get("action"); @@ -100,13 +107,15 @@ const lnurl = { return lnurlAuthDetails; } else { try { - const { data }: { data: LNURLDetails | LNURLError } = await lnurlGet< - LNURLDetails | LNURLError - >( - url, - {}, - { validate: !userInitiated, followRedirects: userInitiated } - ); + const { data } = await axios.get(url.toString(), { + adapter: "fetch", + // https://github.com/lnurl/luds/blob/luds/01.md#http-status-codes-and-content-type + validateStatus: () => true, + }); + + if (!isLNURLResponse(data)) { + throw new Error("Invalid LNURL response"); + } const lnurlDetails = data; @@ -119,18 +128,12 @@ const lnurl = { return lnurlDetails; } catch (e) { - // The service's own error text is safe to surface: the endpoint host has - // already been validated. Transport failures are reported generically so - // the response of an arbitrary endpoint is not relayed back to a caller. + // Only the service's own LNURL error text is surfaced. Transport + // failures and non-LNURL responses are reported generically so the + // content of an arbitrary endpoint is never relayed to the caller. let error: string; if (e instanceof LNURLServiceError) { error = e.message; - } else if ( - !axios.isAxiosError(e) && - e instanceof Error && - e.message.startsWith("Invalid LNURL") - ) { - error = e.message; } else if (this.isLightningAddress(lnurlString)) { error = "Could not reach this lightning address. It may be invalid, or its server may be unavailable."; diff --git a/src/common/lib/lnurlValidation.ts b/src/common/lib/lnurlValidation.ts deleted file mode 100644 index fa070a47eb..0000000000 --- a/src/common/lib/lnurlValidation.ts +++ /dev/null @@ -1,105 +0,0 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; -import ipaddr from "ipaddr.js"; - -/** - * LNURL endpoints are supplied by the visited website but fetched from a - * privileged context that holds broad host permissions. Restrict which - * targets those fetches may reach so a website cannot point them at the - * user's loopback interface, private network, or cloud metadata endpoints. - */ - -const DISALLOWED_HOST_SUFFIXES = [ - ".local", - ".internal", - ".localhost", - ".home.arpa", -]; - -export function isDisallowedLnurlHost(hostname: string): boolean { - const host = hostname - .toLowerCase() - .replace(/^\[|\]$/g, "") - .replace(/\.$/, ""); - if (!host) return true; - if (host === "localhost") return true; - if (DISALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) - return true; - - if (ipaddr.isValid(host)) { - // process() unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d) to the embedded - // IPv4 address, so it is classified by the IPv4 ranges. Everything that is - // not plain unicast (loopback, private, link-local, CGNAT, NAT64, ULA, - // multicast, reserved, ...) is denied. - return ipaddr.process(host).range() !== "unicast"; - } - - return false; -} - -/** - * Validate an LNURL fetch target. Requires https, except .onion hosts which may - * use http (they resolve through Tor, never to a local address). Rejects - * loopback, private, link-local and metadata targets. Returns the parsed URL. - */ -export function assertAllowedLnurlUrl(rawUrl: string | URL): URL { - const url = rawUrl instanceof URL ? rawUrl : new URL(rawUrl); - const isOnion = url.hostname - .toLowerCase() - .replace(/\.$/, "") - .endsWith(".onion"); - - if (url.protocol !== "https:" && !(url.protocol === "http:" && isOnion)) { - throw new Error("Invalid LNURL: only https:// endpoints are allowed"); - } - if (!isOnion && isDisallowedLnurlHost(url.hostname)) { - throw new Error("Invalid LNURL: endpoint host is not allowed"); - } - return url; -} - -/** - * A callback may stay on the origin of the LNURL that produced it whatever that - * origin was (this keeps self-hosted services on a local network working); - * otherwise it has to satisfy the normal restrictions. Cross-host callbacks are - * common for lightning addresses, so the origin is not required to match. - */ -export function assertAllowedCallbackUrl( - callback: string | URL, - lnurlUrl: string | URL | undefined -): URL { - const callbackUrl = callback instanceof URL ? callback : new URL(callback); - if (lnurlUrl) { - const base = lnurlUrl instanceof URL ? lnurlUrl : new URL(lnurlUrl); - if (callbackUrl.origin === base.origin) { - return callbackUrl; - } - } - return assertAllowedLnurlUrl(callbackUrl); -} - -/** - * axios GET guarded for LNURL: validates the target and refuses to follow - * redirects, so a permitted host cannot bounce the request to a denied one. - */ -export async function lnurlGet( - target: string | URL, - config: AxiosRequestConfig = {}, - { validate = true, followRedirects = false } = {} -): Promise> { - const url = validate - ? assertAllowedLnurlUrl(target) - : target instanceof URL - ? target - : new URL(target); - if (followRedirects) { - return axios.get(url.toString(), { ...config, adapter: "fetch" }); - } - return axios.get(url.toString(), { - ...config, - adapter: "fetch", - // the fetch adapter honours fetchOptions.redirect; maxRedirects covers the - // xhr/http adapters should the adapter ever change. - maxRedirects: 0, - fetchOptions: { ...(config.fetchOptions || {}), redirect: "error" }, - }); -} diff --git a/src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts b/src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts new file mode 100644 index 0000000000..d91a25d9fc --- /dev/null +++ b/src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts @@ -0,0 +1,48 @@ +import { isPrivateHost } from "../index"; + +// hostnames are taken from the WHATWG URL parser, which re-serialises IPv6 +// literals (https://[::ffff:127.0.0.1]/ becomes [::ffff:7f00:1]) +const hostnameOf = (url: string) => new URL(url).hostname; + +describe("isPrivateHost", () => { + const privateHosts = [ + "localhost", + "foo.local", + "service.internal", + "app.localhost", + "umbrel.home.arpa", + "127.0.0.1", + "10.0.0.1", + "172.16.5.5", + "192.168.1.1", + "169.254.169.254", // cloud metadata + "100.64.0.1", // CGNAT + "0.0.0.0", + "224.0.0.1", // multicast + "255.255.255.255", // broadcast + "198.18.0.1", // benchmarking + hostnameOf("https://[::1]/"), + hostnameOf("https://[::]/"), + hostnameOf("https://[::ffff:127.0.0.1]/"), // IPv4-mapped loopback + hostnameOf("https://[::ffff:10.0.0.1]/"), // IPv4-mapped RFC1918 + hostnameOf("https://[64:ff9b::127.0.0.1]/"), // NAT64 + hostnameOf("https://[fe80::1]/"), // link-local + hostnameOf("https://[fd00::1]/"), // unique local + hostnameOf("https://[fec0::1]/"), // deprecated site-local + ]; + const publicHosts = [ + "getalby.com", + "walletofsatoshi.com", + "example.onion", + "8.8.8.8", + "172.32.0.1", // just outside RFC1918 + hostnameOf("https://[2606:4700::1111]/"), + ]; + + it.each(privateHosts)("treats %s as private", (host) => { + expect(isPrivateHost(host)).toBe(true); + }); + it.each(publicHosts)("treats %s as public", (host) => { + expect(isPrivateHost(host)).toBe(false); + }); +}); diff --git a/src/extension/background-script/actions/lnurl/auth.ts b/src/extension/background-script/actions/lnurl/auth.ts index bdfc7497c1..45e7e33b7d 100644 --- a/src/extension/background-script/actions/lnurl/auth.ts +++ b/src/extension/background-script/actions/lnurl/auth.ts @@ -1,6 +1,5 @@ import * as secp256k1 from "@noble/secp256k1"; import axios from "axios"; -import { assertAllowedLnurlUrl, lnurlGet } from "~/common/lib/lnurlValidation"; import { Buffer } from "buffer"; import Hex from "crypto-js/enc-hex"; import Utf8 from "crypto-js/enc-utf8"; @@ -43,7 +42,7 @@ export async function authFunction({ throw new Error("LNURL-AUTH FAIL: no account selected"); } - const url = assertAllowedLnurlUrl(lnurlDetails.url); + const url = new URL(lnurlDetails.url); if (!url.host) { throw new Error("Invalid input"); } @@ -117,7 +116,12 @@ export async function authFunction({ loginURL.searchParams.set("t", Date.now().toString()); try { - const authResponse = await lnurlGet(loginURL); + const authResponse = await axios.get( + loginURL.toString(), + { + adapter: "fetch", + } + ); // if the service returned with a HTTP 200 we still check if the response data is OK if (authResponse?.data.status?.toUpperCase() !== "OK") { diff --git a/src/extension/background-script/actions/lnurl/index.ts b/src/extension/background-script/actions/lnurl/index.ts index 1c0588b2be..56e059b577 100644 --- a/src/extension/background-script/actions/lnurl/index.ts +++ b/src/extension/background-script/actions/lnurl/index.ts @@ -1,3 +1,4 @@ +import ipaddr from "ipaddr.js"; import lnurlLib from "~/common/lib/lnurl"; import { isLNURLDetailsError } from "~/common/utils/typeHelpers"; import type { MessageWebLnLnurl, Sender } from "~/types"; @@ -8,6 +9,26 @@ import channelRequestWithPrompt from "./channel"; import payWithPrompt from "./pay"; import withdrawWithPrompt from "./withdraw"; +const LOCAL_HOST_SUFFIXES = [".local", ".internal", ".localhost", ".home.arpa"]; + +/* + LNURLs passed in by a website are fetched from the background script, which + holds broad host permissions. A website must not be able to point those + requests at the user's own machine or local network. +*/ +export function isPrivateHost(hostname: string): boolean { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!host || host === "localhost") return true; + if (LOCAL_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; + // process() unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d); everything that is + // not plain unicast (loopback, private, link-local, CGNAT, NAT64, ...) is + // treated as private. + return ipaddr.isValid(host) && ipaddr.process(host).range() !== "unicast"; +} + /* Main entry point for LNURL calls returns a messagable response: an object with either a `data` or with an `error` @@ -16,10 +37,23 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { if (typeof message.args.lnurlEncoded !== "string") return; let lnurlDetails; try { + const url = lnurlLib.normalizeLnurl(message.args.lnurlEncoded); + if (isPrivateHost(url.hostname)) { + return { error: "Invalid LNURL" }; + } + lnurlDetails = await lnurlLib.getDetails(message.args.lnurlEncoded); if (isLNURLDetailsError(lnurlDetails)) { return { error: lnurlDetails.reason }; } + + // the callback is chosen by the LNURL service, so it is checked as well + if ( + "callback" in lnurlDetails && + isPrivateHost(new URL(lnurlDetails.callback).hostname) + ) { + return { error: "Invalid LNURL" }; + } } catch (e) { return { error: e instanceof Error ? e.message : "Failed to parse LNURL" }; } From a8ab69420d4b252d151a2fd692a61eaa38a3a979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 8 Sep 2026 23:55:44 +0200 Subject: [PATCH 6/8] fix: apply the LNURL host check to page-advertised LNURLs and trim callback replies isPrivateHost moves to common/lib/lnurl.ts so the publisher widget in the popup, which loads the LNURL a page advertises in its meta tags, runs the same check as the webln.lnurl entry point. The channel and withdraw prompts and the lnurl-auth action now reply with only the LNURL response fields (status, reason) instead of the raw callback body. Claude-Session: https://claude.ai/code/session_01KsUBBAqgYc37oxJHibvR4u --- src/app/screens/Home/PublisherLnData.tsx | 4 +++ src/app/screens/LNURLChannel/index.tsx | 3 ++- src/app/screens/LNURLWithdraw/index.tsx | 2 +- .../lib}/__tests__/isPrivateHost.test.ts | 6 ++--- src/common/lib/lnurl.ts | 22 ++++++++++++++++ .../background-script/actions/lnurl/auth.ts | 5 +++- .../background-script/actions/lnurl/index.ts | 25 ++----------------- 7 files changed, 38 insertions(+), 29 deletions(-) rename src/{extension/background-script/actions/lnurl => common/lib}/__tests__/isPrivateHost.test.ts (90%) diff --git a/src/app/screens/Home/PublisherLnData.tsx b/src/app/screens/Home/PublisherLnData.tsx index 487f4b5276..aed35c9547 100644 --- a/src/app/screens/Home/PublisherLnData.tsx +++ b/src/app/screens/Home/PublisherLnData.tsx @@ -33,6 +33,10 @@ export const PublisherLnData: FC = ({ lnData }) => { if (lnData.method === "lnurl") { const lnurl = lnData.address; + if (lnurlLib.isPrivateHost(lnurlLib.normalizeLnurl(lnurl).hostname)) { + toast.error("Invalid LNURL"); + return; + } const lnurlDetails = await lnurlLib.getDetails(lnurl); if (isLNURLDetailsError(lnurlDetails)) { toast.error(lnurlDetails.reason); diff --git a/src/app/screens/LNURLChannel/index.tsx b/src/app/screens/LNURLChannel/index.tsx index 4c61454e78..9cd307cac9 100644 --- a/src/app/screens/LNURLChannel/index.tsx +++ b/src/app/screens/LNURLChannel/index.tsx @@ -69,7 +69,8 @@ function LNURLChannel() { // ATTENTION: if this LNURL is called through `webln.lnurl` then we immediately return and return the response. This closes the window which means the user will NOT see the above successAction. // We assume this is OK when it is called through webln. if (navState.isPrompt) { - msg.reply(callbackResponse?.data); + const { status, reason } = callbackResponse.data ?? {}; + msg.reply({ status, reason }); } } catch (e) { console.error(e); diff --git a/src/app/screens/LNURLWithdraw/index.tsx b/src/app/screens/LNURLWithdraw/index.tsx index 3deebfb9ab..03ddc7e9b2 100644 --- a/src/app/screens/LNURLWithdraw/index.tsx +++ b/src/app/screens/LNURLWithdraw/index.tsx @@ -80,7 +80,7 @@ function LNURLWithdraw() { // ATTENTION: if this LNURL is called through `webln.lnurl` then we immediately return and return the response. This closes the window which means the user will NOT see the above successAction. // We assume this is OK when it is called through webln. if (navState.isPrompt) { - msg.reply(response.data); + msg.reply({ status: response.data.status }); } } else { throw new Error(response.data.reason); diff --git a/src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts b/src/common/lib/__tests__/isPrivateHost.test.ts similarity index 90% rename from src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts rename to src/common/lib/__tests__/isPrivateHost.test.ts index d91a25d9fc..2e04f5a1f0 100644 --- a/src/extension/background-script/actions/lnurl/__tests__/isPrivateHost.test.ts +++ b/src/common/lib/__tests__/isPrivateHost.test.ts @@ -1,4 +1,4 @@ -import { isPrivateHost } from "../index"; +import lnurlLib from "../lnurl"; // hostnames are taken from the WHATWG URL parser, which re-serialises IPv6 // literals (https://[::ffff:127.0.0.1]/ becomes [::ffff:7f00:1]) @@ -40,9 +40,9 @@ describe("isPrivateHost", () => { ]; it.each(privateHosts)("treats %s as private", (host) => { - expect(isPrivateHost(host)).toBe(true); + expect(lnurlLib.isPrivateHost(host)).toBe(true); }); it.each(publicHosts)("treats %s as public", (host) => { - expect(isPrivateHost(host)).toBe(false); + expect(lnurlLib.isPrivateHost(host)).toBe(false); }); }); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index 772e3ef608..aa3a58810f 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -1,5 +1,6 @@ import axios from "axios"; import lightningPayReq from "bolt11-signet"; +import ipaddr from "ipaddr.js"; import { isLNURLDetailsError } from "~/common/utils/typeHelpers"; import { LNURLAuthServiceResponse, @@ -15,6 +16,26 @@ class LNURLServiceError extends Error {} const LNURL_TAGS = ["payRequest", "withdrawRequest", "channelRequest", "login"]; +const LOCAL_HOST_SUFFIXES = [".local", ".internal", ".localhost", ".home.arpa"]; + +/** + * LNURLs passed in by a website are fetched from the extension, which holds + * broad host permissions. A website must not be able to point those requests + * at the user's own machine or local network. + */ +const isPrivateHost = (hostname: string): boolean => { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (!host || host === "localhost") return true; + if (LOCAL_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; + // process() unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d); everything that is + // not plain unicast (loopback, private, link-local, CGNAT, NAT64, ...) is + // treated as private. + return ipaddr.isValid(host) && ipaddr.process(host).range() !== "unicast"; +}; + /** * Only a response that looks like an LNURL service response is processed any * further. Anything else (HTML, plain text, unrelated JSON) is rejected with a @@ -88,6 +109,7 @@ const lnurl = { }, normalizeLnurl, + isPrivateHost, async getDetails(lnurlString: string): Promise { const url = normalizeLnurl(lnurlString); diff --git a/src/extension/background-script/actions/lnurl/auth.ts b/src/extension/background-script/actions/lnurl/auth.ts index 45e7e33b7d..7aa618b243 100644 --- a/src/extension/background-script/actions/lnurl/auth.ts +++ b/src/extension/background-script/actions/lnurl/auth.ts @@ -139,7 +139,10 @@ export async function authFunction({ success: true, status: authResponse.data.status, reason: authResponse.data.reason, - authResponseData: authResponse.data, + authResponseData: { + status: authResponse.data.status, + ...(authResponse.data.reason && { reason: authResponse.data.reason }), + }, }; return response; diff --git a/src/extension/background-script/actions/lnurl/index.ts b/src/extension/background-script/actions/lnurl/index.ts index 56e059b577..51963f84cb 100644 --- a/src/extension/background-script/actions/lnurl/index.ts +++ b/src/extension/background-script/actions/lnurl/index.ts @@ -1,4 +1,3 @@ -import ipaddr from "ipaddr.js"; import lnurlLib from "~/common/lib/lnurl"; import { isLNURLDetailsError } from "~/common/utils/typeHelpers"; import type { MessageWebLnLnurl, Sender } from "~/types"; @@ -9,26 +8,6 @@ import channelRequestWithPrompt from "./channel"; import payWithPrompt from "./pay"; import withdrawWithPrompt from "./withdraw"; -const LOCAL_HOST_SUFFIXES = [".local", ".internal", ".localhost", ".home.arpa"]; - -/* - LNURLs passed in by a website are fetched from the background script, which - holds broad host permissions. A website must not be able to point those - requests at the user's own machine or local network. -*/ -export function isPrivateHost(hostname: string): boolean { - const host = hostname - .toLowerCase() - .replace(/^\[|\]$/g, "") - .replace(/\.$/, ""); - if (!host || host === "localhost") return true; - if (LOCAL_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; - // process() unwraps IPv4-mapped IPv6 (::ffff:a.b.c.d); everything that is - // not plain unicast (loopback, private, link-local, CGNAT, NAT64, ...) is - // treated as private. - return ipaddr.isValid(host) && ipaddr.process(host).range() !== "unicast"; -} - /* Main entry point for LNURL calls returns a messagable response: an object with either a `data` or with an `error` @@ -38,7 +17,7 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { let lnurlDetails; try { const url = lnurlLib.normalizeLnurl(message.args.lnurlEncoded); - if (isPrivateHost(url.hostname)) { + if (lnurlLib.isPrivateHost(url.hostname)) { return { error: "Invalid LNURL" }; } @@ -50,7 +29,7 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { // the callback is chosen by the LNURL service, so it is checked as well if ( "callback" in lnurlDetails && - isPrivateHost(new URL(lnurlDetails.callback).hostname) + lnurlLib.isPrivateHost(new URL(lnurlDetails.callback).hostname) ) { return { error: "Invalid LNURL" }; } From da3a036bd2c7e9c061d0fdb7a5e795dddd749e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Wed, 9 Sep 2026 00:00:57 +0200 Subject: [PATCH 7/8] fix: require https for website-supplied LNURLs and callbacks Per LUD-01, only onion services may use http. Applies to the webln.lnurl entry point only; user-pasted LNURLs are unaffected. Claude-Session: https://claude.ai/code/session_01KsUBBAqgYc37oxJHibvR4u --- .../background-script/actions/lnurl/index.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/extension/background-script/actions/lnurl/index.ts b/src/extension/background-script/actions/lnurl/index.ts index 51963f84cb..3986100a2e 100644 --- a/src/extension/background-script/actions/lnurl/index.ts +++ b/src/extension/background-script/actions/lnurl/index.ts @@ -8,6 +8,15 @@ import channelRequestWithPrompt from "./channel"; import payWithPrompt from "./pay"; import withdrawWithPrompt from "./withdraw"; +// LUD-01: LNURL endpoints are https; only onion services may use http +function isAllowedTarget(url: URL): boolean { + const isOnion = url.hostname.toLowerCase().endsWith(".onion"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && isOnion)) { + return false; + } + return !lnurlLib.isPrivateHost(url.hostname); +} + /* Main entry point for LNURL calls returns a messagable response: an object with either a `data` or with an `error` @@ -16,8 +25,7 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { if (typeof message.args.lnurlEncoded !== "string") return; let lnurlDetails; try { - const url = lnurlLib.normalizeLnurl(message.args.lnurlEncoded); - if (lnurlLib.isPrivateHost(url.hostname)) { + if (!isAllowedTarget(lnurlLib.normalizeLnurl(message.args.lnurlEncoded))) { return { error: "Invalid LNURL" }; } @@ -29,7 +37,7 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { // the callback is chosen by the LNURL service, so it is checked as well if ( "callback" in lnurlDetails && - lnurlLib.isPrivateHost(new URL(lnurlDetails.callback).hostname) + !isAllowedTarget(new URL(lnurlDetails.callback)) ) { return { error: "Invalid LNURL" }; } From 871a72f100fdb14fa82a233899b52e32a04991f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Wed, 9 Sep 2026 00:18:03 +0200 Subject: [PATCH 8/8] fix: require https for page-advertised LNURLs and translate the error The scheme and host rule moves to lnurlLib.isAllowedTarget so the popup's publisher widget applies it too. Claude-Session: https://claude.ai/code/session_01KsUBBAqgYc37oxJHibvR4u --- src/app/screens/Home/PublisherLnData.tsx | 4 ++-- src/common/lib/lnurl.ts | 9 +++++++++ .../background-script/actions/lnurl/index.ts | 17 ++++++----------- src/i18n/locales/en/translation.json | 1 + 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/app/screens/Home/PublisherLnData.tsx b/src/app/screens/Home/PublisherLnData.tsx index aed35c9547..ff8365f48e 100644 --- a/src/app/screens/Home/PublisherLnData.tsx +++ b/src/app/screens/Home/PublisherLnData.tsx @@ -33,8 +33,8 @@ export const PublisherLnData: FC = ({ lnData }) => { if (lnData.method === "lnurl") { const lnurl = lnData.address; - if (lnurlLib.isPrivateHost(lnurlLib.normalizeLnurl(lnurl).hostname)) { - toast.error("Invalid LNURL"); + if (!lnurlLib.isAllowedTarget(lnurlLib.normalizeLnurl(lnurl))) { + toast.error(t("invalid_lnurl")); return; } const lnurlDetails = await lnurlLib.getDetails(lnurl); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index aa3a58810f..4e02cab1c4 100644 --- a/src/common/lib/lnurl.ts +++ b/src/common/lib/lnurl.ts @@ -111,6 +111,15 @@ const lnurl = { normalizeLnurl, isPrivateHost, + /** LUD-01: LNURL endpoints are https; only onion services may use http. */ + isAllowedTarget(url: URL): boolean { + const isOnion = url.hostname.toLowerCase().endsWith(".onion"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && isOnion)) { + return false; + } + return !isPrivateHost(url.hostname); + }, + async getDetails(lnurlString: string): Promise { const url = normalizeLnurl(lnurlString); const searchParamsTag = url.searchParams.get("tag"); diff --git a/src/extension/background-script/actions/lnurl/index.ts b/src/extension/background-script/actions/lnurl/index.ts index 3986100a2e..25f9ef2a82 100644 --- a/src/extension/background-script/actions/lnurl/index.ts +++ b/src/extension/background-script/actions/lnurl/index.ts @@ -8,15 +8,6 @@ import channelRequestWithPrompt from "./channel"; import payWithPrompt from "./pay"; import withdrawWithPrompt from "./withdraw"; -// LUD-01: LNURL endpoints are https; only onion services may use http -function isAllowedTarget(url: URL): boolean { - const isOnion = url.hostname.toLowerCase().endsWith(".onion"); - if (url.protocol !== "https:" && !(url.protocol === "http:" && isOnion)) { - return false; - } - return !lnurlLib.isPrivateHost(url.hostname); -} - /* Main entry point for LNURL calls returns a messagable response: an object with either a `data` or with an `error` @@ -25,7 +16,11 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { if (typeof message.args.lnurlEncoded !== "string") return; let lnurlDetails; try { - if (!isAllowedTarget(lnurlLib.normalizeLnurl(message.args.lnurlEncoded))) { + if ( + !lnurlLib.isAllowedTarget( + lnurlLib.normalizeLnurl(message.args.lnurlEncoded) + ) + ) { return { error: "Invalid LNURL" }; } @@ -37,7 +32,7 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { // the callback is chosen by the LNURL service, so it is checked as well if ( "callback" in lnurlDetails && - !isAllowedTarget(new URL(lnurlDetails.callback)) + !lnurlLib.isAllowedTarget(new URL(lnurlDetails.callback)) ) { return { error: "Invalid LNURL" }; } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index c428eb7f68..11f975c5a3 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -339,6 +339,7 @@ } }, "home": { + "invalid_lnurl": "Invalid LNURL", "actions": { "send_satoshis": "⚡️ Send Satoshis ⚡️", "enable_now": "Enable Now"