diff --git a/jest.custom-test-environment.js b/jest.custom-test-environment.js index 4656351f2c..1c6d63c73e 100644 --- a/jest.custom-test-environment.js +++ b/jest.custom-test-environment.js @@ -12,6 +12,23 @@ 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. + // 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/package.json b/package.json index cea797d015..31dee5da6a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,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/app/screens/Home/PublisherLnData.tsx b/src/app/screens/Home/PublisherLnData.tsx index 487f4b5276..ff8365f48e 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.isAllowedTarget(lnurlLib.normalizeLnurl(lnurl))) { + toast.error(t("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/common/lib/__tests__/isPrivateHost.test.ts b/src/common/lib/__tests__/isPrivateHost.test.ts new file mode 100644 index 0000000000..2e04f5a1f0 --- /dev/null +++ b/src/common/lib/__tests__/isPrivateHost.test.ts @@ -0,0 +1,48 @@ +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]) +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(lnurlLib.isPrivateHost(host)).toBe(true); + }); + it.each(publicHosts)("treats %s as public", (host) => { + expect(lnurlLib.isPrivateHost(host)).toBe(false); + }); +}); diff --git a/src/common/lib/lnurl.ts b/src/common/lib/lnurl.ts index 63e11de8c9..4e02cab1c4 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, @@ -10,6 +11,45 @@ import { import { bech32Decode } from "../utils/helpers"; +/** An error returned by the LNURL service itself (LUD-06 `status: "ERROR"`). */ +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 + * 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 @@ -68,6 +108,18 @@ const lnurl = { return null; }, + 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"); @@ -86,17 +138,20 @@ const lnurl = { return lnurlAuthDetails; } else { try { - const { data }: { data: LNURLDetails | LNURLError } = await axios.get( - url.toString(), - { - adapter: "fetch", - } - ); + 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; if (isLNURLDetailsError(lnurlDetails)) { - throw new Error(lnurlDetails.reason); + throw new LNURLServiceError(lnurlDetails.reason); } else { lnurlDetails.domain = url.hostname; lnurlDetails.url = url.toString(); @@ -104,16 +159,17 @@ const lnurl = { return lnurlDetails; } catch (e) { - let error = ""; - if (axios.isAxiosError(e)) { - 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) { + // 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 (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/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 1c0588b2be..25f9ef2a82 100644 --- a/src/extension/background-script/actions/lnurl/index.ts +++ b/src/extension/background-script/actions/lnurl/index.ts @@ -16,10 +16,26 @@ async function lnurl(message: MessageWebLnLnurl, sender: Sender) { if (typeof message.args.lnurlEncoded !== "string") return; let lnurlDetails; try { + if ( + !lnurlLib.isAllowedTarget( + lnurlLib.normalizeLnurl(message.args.lnurlEncoded) + ) + ) { + 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 && + !lnurlLib.isAllowedTarget(new URL(lnurlDetails.callback)) + ) { + return { error: "Invalid LNURL" }; + } } catch (e) { return { error: e instanceof Error ? e.message : "Failed to parse 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" diff --git a/yarn.lock b/yarn.lock index 5dac722484..47068b3899 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6641,6 +6641,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"