-
Notifications
You must be signed in to change notification settings - Fork 228
fix: validate LNURL request targets and responses #3597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
12b3a10
eaf4d1f
fb0deb8
178fb2c
9159d70
f9366a3
a8ab694
da3a036
871a72f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * 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<string, unknown>; | ||
| 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<LNURLError | LNURLDetails> { | ||
| const url = normalizeLnurl(lnurlString); | ||
| const searchParamsTag = url.searchParams.get("tag"); | ||
|
|
@@ -86,34 +138,38 @@ const lnurl = { | |
| return lnurlAuthDetails; | ||
| } else { | ||
| try { | ||
| const { data }: { data: LNURLDetails | LNURLError } = await axios.get( | ||
| url.toString(), | ||
| { | ||
| adapter: "fetch", | ||
| } | ||
| ); | ||
| const { data } = await axios.get<unknown>(url.toString(), { | ||
| adapter: "fetch", | ||
| // https://github.com/lnurl/luds/blob/luds/01.md#http-status-codes-and-content-type | ||
| validateStatus: () => true, | ||
| }); | ||
|
Comment on lines
+141
to
+145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🌐 Web query:
💡 Result: In Axios 1.20.0, the Citations:
SSRF Reachability: External Reject redirects for website-supplied LNURLs. The fetch adapter follows redirects by default. Add 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redirects are followed on purpose. The website only ever receives the fixed error message or the LNURL status fields, never the response body, and refusing redirects broke lightning addresses whose provider redirects the lnurlp endpoint (speed.app and bitcoinjungle.app do).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== getDetails call sites =='
rg -n -C 5 '\bgetDetails\s*\(' --glob '*.{ts,tsx}' .
printf '%s\n' '== isAllowedTarget / normalizeLnurl call sites =='
rg -n -C 5 '\b(isAllowedTarget|normalizeLnurl)\s*\(' --glob '*.{ts,tsx}' .
printf '%s\n' '== relevant file outlines =='
fd -a -t f -e ts -e tsx . src | rg '(lnurl|PublisherLnData)'Length of output: 9818
The current shared The proposed fix is therefore not compatible with this PR's intended behavior. The remaining issue is an accepted blind-SSRF risk from redirects, not response-data exposure. A future fix would need redirect-aware validation for website-supplied requests while preserving redirects for user-initiated requests. 🐇 ✏️ Learnings added
You are interacting with an AI system. |
||
|
|
||
| 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(); | ||
| } | ||
|
|
||
| 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); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.