From 6045e288308499a79a66850f0fea35f1b902cb4d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 21:02:59 -0400 Subject: [PATCH] fix(cashu): validate a token's mint URL before contacting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MintHttpClient` only trimmed trailing slashes — no scheme check, no host check — and `token.mint` comes verbatim from any pasted or posted Cashu token. Tapping Redeem on a token in someone's note therefore made the device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:`, LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all — plus it disclosed the user's IP to whoever controlled the URL. Validation now runs in the constructor, so no caller can issue a request before it. The rule: `https://` to a public host, or `http://` to a `.onion` host, and nothing else. Onion mints matter — a blanket "https only" rule would have silently broken every Tor mint. Rejected hosts cover the private/loopback/link-local/unique-local ranges plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host, so allowing them buys nothing and leaks reachability. The bypasses are what make this worth care, and each has a test: IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`), the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`, `127.1`), trailing-dot hosts, and userinfo disguise (`https://mint.example.com@127.0.0.1/`) — handled by splitting on the LAST `@`. The host parse is hand-rolled rather than delegated to `java.net.URI`/`HttpUrl` precisely because those normalise these forms inconsistently. A mint the user added to their own wallet is exempt from the host and https rules — a self-hosted mint on a LAN is a legitimate setup, and the threat here is a *pasted, untrusted* token pointing inward, not a mint the user chose. The exemption never relaxes the scheme check. It is threaded properly rather than TODO'd: the melt path passes the wallet's known mints and marks the mint user-configured only on a match; the wallet ops and CLI pass it directly, since those URLs are the user's own. Refusal gets its own message rather than reusing the mint-error string, whose wording would have misattributed our own refusal to the mint. DNS rebinding is out of scope and noted in a comment — the check runs pre-resolution and cannot defend against a host that resolves differently on the second lookup. Verified by disabling the scheme and host checks: 11 of 20 tests fail, every rejection case among them, and every allow case still passes. Co-Authored-By: Claude Opus 4.8 --- .../service/cashu/melt/MeltProcessor.kt | 20 +- .../ui/screen/loggedIn/AccountViewModel.kt | 13 +- amethyst/src/main/res/values/strings.xml | 2 + .../cli/commands/cashu/CashuMintCommands.kt | 5 +- .../commons/cashu/ops/CashuWalletOps.kt | 9 +- .../mintApi/CashuMintUrlValidator.kt | 317 ++++++++++++++++++ .../nip60Cashu/mintApi/MintHttpClient.kt | 12 +- .../mintApi/CashuMintUrlValidatorTest.kt | 223 ++++++++++++ 8 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index d9d13813de..f570c480ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException @@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException * it also picks up NUT-02 per-input fee handling for free. */ class MeltProcessor { + /** + * @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token + * pointing at one of those was, by definition, issued by a mint the user + * deliberately added, so it is exempt from the private-address block that + * [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies + * to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate). + */ suspend fun melt( token: CashuToken, lud16: String, okHttpClient: (String) -> OkHttpClient, context: Context, + knownWalletMints: Set = emptySet(), ): MeltResult { try { - val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient)) + val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) } + val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient)) val proofs = token.proofs // A Lightning address must commit to an amount before we know the @@ -106,6 +116,14 @@ class MeltProcessor { } catch (e: Exception) { if (e is CancellationException) throw e if (e is LightningAddressResolver.LightningAddressError) throw e + // The mint URL was refused before any request went out: this is OUR + // message, not the mint's, so don't dress it up as "the mint said". + if (e is MintUrlException) { + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_unsafe_mint_url), + stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message), + ) + } throw LightningAddressResolver.LightningAddressError( stringRes(context, R.string.cashu_failed_redemption), stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 143ba838c4..fd8e385c2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2380,7 +2380,18 @@ class AccountViewModel( if (lud16 != null) { viewModelScope.launch(Dispatchers.IO) { try { - val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context) + val meltResult = + MeltProcessor().melt( + token, + lud16, + httpClientBuilder::okHttpClientForMoney, + context, + // Mints the user deliberately added are exempt from the + // private-address block (self-hosted LAN mints are legit). + knownWalletMints = + account.cashuWalletState.mints.value + .toSet(), + ) onDone( stringRes(context, R.string.cashu_successful_redemption), stringRes( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0ef4fe5dec..97148847fb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2605,6 +2605,8 @@ Could not redeem Cashu Mint provided the following error message: %1$s + Unsafe Cashu mint address + Amethyst did not contact this token\'s mint. %1$s Cashu Received %1$s sats were sent to your wallet. (Fees: %2$s sats) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt index 8881a5e36b..6102eb27f5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt @@ -59,7 +59,8 @@ object CashuMintCommands { val url = args.positional(0, "mint-url") args.rejectUnknown() return try { - val dto = MintHttpClient(url) { okhttp }.info() + // userConfigured: the operator typed this URL on the command line. + val dto = MintHttpClient(url, userConfigured = true) { okhttp }.info() Output.emit( mapOf( "mint_url" to url, @@ -82,7 +83,7 @@ object CashuMintCommands { val url = args.positional(0, "mint-url") args.rejectUnknown() return try { - val dto = MintHttpClient(url) { okhttp }.info(force = true) + val dto = MintHttpClient(url, userConfigured = true) { okhttp }.info(force = true) Output.emit(mapOf("mint_url" to url, "mint_info" to dto)) 0 } catch (e: MintHttpException) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index a84604e7be..530006ad33 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -125,7 +125,9 @@ class CashuWalletOps( private fun ops(mintUrl: String): CashuMintOperations = opsCache.getOrPut(mintUrl.trimEnd('/')) { - CashuMintOperations(MintHttpClient(mintUrl, okHttpClient), secretFactory) + // userConfigured: these are the mints of the user's own NIP-60 wallet, + // added deliberately, so a self-hosted mint on the LAN stays usable. + CashuMintOperations(MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient), secretFactory) } /** @@ -923,8 +925,11 @@ class CashuWalletOps( * is typo'd, points at a non-Cashu host, or is otherwise unreachable. * * Throws on failure so the UI can surface the underlying reason. + * + * `userConfigured = true`: the URL was typed by the user into the Add-Mint UI, + * so a self-hosted mint on the LAN is a legitimate target here. */ - suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, okHttpClient).info().name + suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient).info().name /** * Fetch the currently-active keyset id for [mintUrl]. Cheap wrapper diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt new file mode 100644 index 0000000000..b0324f0e5c --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.mintApi + +/** + * Thrown when a mint URL is refused before any request is issued. The message is + * user-facing: it is surfaced verbatim in the "could not redeem" dialog. + */ +class MintUrlException( + message: String, +) : RuntimeException(message) + +/** + * Gatekeeper for the `mintUrl` a [MintHttpClient] is about to talk to. + * + * A Cashu token carries its own mint URL (`token.mint`) and that URL is attacker + * controlled: anybody can paste or post a token. Redeeming it makes the device + * issue HTTP requests to whatever it says, which is an SSRF primitive against + * anything the phone can reach (`http://127.0.0.1:`, the home router at + * `192.168.1.1`, `169.254.169.254` cloud metadata) plus an IP-address discloser + * for whoever controls the URL. + * + * The rule: + * - `https://` to a public host is allowed. + * - `http://` is allowed **only** to a `.onion` host. Amethyst supports Tor and + * onion mints are legitimate; a blanket "https only" would break them. Onion + * names are self-authenticating and never resolve to a local address, so the + * private-range checks below don't apply to them. + * - Any other scheme (`file:`, `ftp:`, `data:`, …) is refused. + * - Loopback, RFC1918 private, link-local, CGNAT, unique-local, unspecified, + * multicast and broadcast addresses are refused, including via the usual + * spellings: IPv4-mapped/compatible IPv6 (`::ffff:127.0.0.1`), `inet_aton` + * decimal/octal/hex forms (`http://2130706433`, `http://0177.0.0.1`), + * `http://user@127.0.0.1`, and a trailing-dot hostname (`localhost.`). + * + * [userConfigured] switches the host checks off (the scheme check still runs). + * Pass it only for a mint the user deliberately added — a mint already in their + * NIP-60 wallet, or one they typed into the CLI. A self-hosted mint on the LAN + * is a legitimate setup; the threat modelled here is a *pasted, untrusted* token + * pointing at an internal address. + * + * NOTE: this is a pre-resolution check on the literal host, so it does not stop + * DNS rebinding (a public name that resolves to 127.0.0.1). That is deliberately + * out of scope — closing it needs a resolve-then-pin socket factory, not a URL + * check. + */ +object CashuMintUrlValidator { + /** + * Validates [mintUrl] and returns the base URL to build request paths from + * (trailing slashes stripped, matching the previous [MintHttpClient] behaviour). + * + * @throws MintUrlException with a user-facing reason when the URL is refused. + */ + fun validatedBaseUrl( + mintUrl: String, + userConfigured: Boolean = false, + ): String { + val url = mintUrl.trim() + if (url.isEmpty()) throw MintUrlException("The token does not name a mint.") + + val schemeEnd = url.indexOf("://") + if (schemeEnd <= 0) throw MintUrlException("The mint address \"$url\" is not an https address.") + + val scheme = url.substring(0, schemeEnd).lowercase() + if (scheme != "https" && scheme != "http") { + throw MintUrlException("The mint address uses the unsupported \"$scheme:\" scheme. Only https is allowed.") + } + + val host = hostOf(url.substring(schemeEnd + 3)) + if (host.isEmpty()) throw MintUrlException("The mint address \"$url\" has no host.") + + val isOnion = host == "onion" || host.endsWith(".onion") + + if (scheme == "http" && !isOnion && !userConfigured) { + throw MintUrlException("The mint address \"$url\" is not encrypted (http). Only https, or an .onion address over Tor, is allowed.") + } + + if (!userConfigured && !isOnion && isPrivateHost(host)) { + throw MintUrlException("The mint address \"$url\" points at a private or local address. Amethyst will not contact it.") + } + + return url.trimEnd('/') + } + + /** + * Extracts the host from the part of the URL that follows `scheme://`: + * drops the path/query/fragment, drops any `user:pass@` prefix (so + * `http://mint.example.com@127.0.0.1/` is correctly read as `127.0.0.1`), + * drops the port, unwraps a `[…]` IPv6 literal, and normalises case plus + * the FQDN trailing dot. + */ + private fun hostOf(afterScheme: String): String { + var rest = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#') + // userinfo goes up to the LAST '@' — an attacker can embed one in the password. + val at = rest.lastIndexOf('@') + if (at >= 0) rest = rest.substring(at + 1) + + val host = + if (rest.startsWith("[")) { + rest.substringAfter('[').substringBefore(']') + } else { + rest.substringBefore(':') + } + + return host.lowercase().trimEnd('.') + } + + /** + * True when [host] is a literal address inside a range we must never reach + * from an untrusted token. Names that are not IP literals return false: the + * check is pre-resolution (see the DNS-rebinding note on the object), except + * for `localhost`, which is a name but always local. + */ + fun isPrivateHost(host: String): Boolean { + if (host == "localhost" || host.endsWith(".localhost")) return true + + if (host.contains(':')) { + val v6 = parseIpv6(host) ?: return false + return isPrivateIpv6(v6) + } + + val v4 = parseIpv4(host) ?: return false + return isPrivateIpv4(v4) + } + + // ------------------------------------------------------------------ + // IPv4 + // ------------------------------------------------------------------ + + /** + * `inet_aton`-style parse: accepts 1..4 parts, each decimal, octal (`0…`) or + * hex (`0x…`), because that is what a C resolver — and therefore a lot of the + * stack below us — will happily accept. Returns the address as an unsigned + * 32-bit value in a [Long], or null when [host] is not an IPv4 literal at all. + */ + fun parseIpv4(host: String): Long? { + if (host.isEmpty()) return null + val parts = host.split('.') + if (parts.size > 4) return null + + val values = ArrayList(parts.size) + for (part in parts) { + values.add(parseIpv4Part(part) ?: return null) + } + + // The last part absorbs every byte the earlier parts didn't name: + // "127.1" is 127.0.0.1, "2130706433" is 127.0.0.1. + val n = values.size + var result = 0L + for (i in 0 until n - 1) { + val v = values[i] + if (v > 255L) return null + result = result or (v shl (8 * (3 - i))) + } + val tailMax = (1L shl (8 * (4 - (n - 1)))) - 1L + val tail = values[n - 1] + if (tail > tailMax) return null + return result or tail + } + + private fun parseIpv4Part(part: String): Long? { + if (part.isEmpty()) return null + return when { + part.startsWith("0x") || part.startsWith("0X") -> { + val digits = part.substring(2) + if (digits.isEmpty() || !digits.all { it.isHexDigit() }) return null + digits.toLongOrNull(16) + } + part.length > 1 && part[0] == '0' -> { + val digits = part.substring(1) + if (!digits.all { it in '0'..'7' }) return null + digits.toLongOrNull(8) + } + else -> { + if (!part.all { it in '0'..'9' }) return null + part.toLongOrNull() + } + }?.takeIf { it >= 0 && it <= 0xFFFFFFFFL } + } + + private fun isPrivateIpv4(addr: Long): Boolean { + val a = ((addr shr 24) and 0xFF).toInt() + val b = ((addr shr 16) and 0xFF).toInt() + return when { + a == 0 -> true // 0.0.0.0/8 — "this network", includes 0.0.0.0 + a == 10 -> true // RFC1918 + a == 127 -> true // loopback + a == 169 && b == 254 -> true // link-local + a == 172 && b in 16..31 -> true // RFC1918 + a == 192 && b == 168 -> true // RFC1918 + a == 100 && b in 64..127 -> true // CGNAT, RFC6598 + a == 192 && b == 0 -> true // 192.0.0.0/24 IETF protocol assignments + a in 224..255 -> true // multicast + reserved + 255.255.255.255 + else -> false + } + } + + // ------------------------------------------------------------------ + // IPv6 + // ------------------------------------------------------------------ + + /** Parses an IPv6 literal (with optional `::` run and optional trailing IPv4) into 16 bytes. */ + fun parseIpv6(literal: String): ByteArray? { + // A zone id (fe80::1%wlan0) doesn't change which range we're in. + val text = literal.substringBefore('%') + if (text.isEmpty()) return null + + val out = ByteArray(16) + val doubleColon = text.indexOf("::") + if (text.indexOf("::", doubleColon + 1) >= 0) return null // more than one "::" + + val headText = if (doubleColon >= 0) text.substring(0, doubleColon) else text + val tailText = if (doubleColon >= 0) text.substring(doubleColon + 2) else "" + + val head = splitGroups(headText) ?: return null + val tail = splitGroups(tailText) ?: return null + + // A trailing dotted-quad ("::ffff:127.0.0.1") counts as the last two groups. + val headBytes = groupsToBytes(head) ?: return null + val tailBytes = groupsToBytes(tail) ?: return null + + if (doubleColon < 0) { + if (headBytes.size != 16) return null + return headBytes + } + if (headBytes.size + tailBytes.size > 14) return null // "::" must stand for >= 1 group + + headBytes.copyInto(out, 0) + tailBytes.copyInto(out, 16 - tailBytes.size) + return out + } + + private fun splitGroups(text: String): List? { + if (text.isEmpty()) return emptyList() + val groups = text.split(':') + if (groups.any { it.isEmpty() }) return null + return groups + } + + private fun groupsToBytes(groups: List): ByteArray? { + val bytes = ArrayList(16) + for ((index, group) in groups.withIndex()) { + if (group.contains('.')) { + // Only legal as the final element, and only in strict dotted-quad form. + if (index != groups.size - 1) return null + val quad = group.split('.') + if (quad.size != 4) return null + for (q in quad) { + if (q.isEmpty() || q.length > 3 || !q.all { it in '0'..'9' }) return null + val v = q.toInt() + if (v > 255) return null + bytes.add(v.toByte()) + } + } else { + if (group.length > 4 || !group.all { it.isHexDigit() }) return null + val v = group.toInt(16) + bytes.add(((v shr 8) and 0xFF).toByte()) + bytes.add((v and 0xFF).toByte()) + } + } + if (bytes.size > 16) return null + return bytes.toByteArray() + } + + private fun isPrivateIpv6(addr: ByteArray): Boolean { + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) carry an + // IPv4 address; judge them by the IPv4 rules or ::ffff:127.0.0.1 walks in. + val first10Zero = (0 until 10).all { addr[it].toInt() == 0 } + if (first10Zero) { + val ffff = (addr[10].toInt() and 0xFF) == 0xFF && (addr[11].toInt() and 0xFF) == 0xFF + val compat = addr[10].toInt() == 0 && addr[11].toInt() == 0 + if (ffff || compat) { + val v4 = + ((addr[12].toLong() and 0xFF) shl 24) or + ((addr[13].toLong() and 0xFF) shl 16) or + ((addr[14].toLong() and 0xFF) shl 8) or + (addr[15].toLong() and 0xFF) + // ::0.0.0.0 / ::1 fall out of the IPv4 rules too (0/8 is blocked). + if (compat && v4 <= 1L) return true + return isPrivateIpv4(v4) + } + } + + val b0 = addr[0].toInt() and 0xFF + val b1 = addr[1].toInt() and 0xFF + + // :: (unspecified) and ::1 (loopback) + if (addr.all { it.toInt() == 0 }) return true + if ((0..14).all { addr[it].toInt() == 0 } && addr[15].toInt() == 1) return true + + if (b0 == 0xFF) return true // ff00::/8 multicast + if (b0 == 0xFE && (b1 and 0xC0) == 0x80) return true // fe80::/10 link-local + if ((b0 and 0xFE) == 0xFC) return true // fc00::/7 unique-local + + return false + } + + private fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index 35b39110cb..c6cdd37c4e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -57,12 +57,22 @@ class MintProtocolException( * * Each instance is bound to a single mint URL (e.g. `https://mint.example.com`). * Trailing slashes are stripped on construction. + * + * The URL is validated by [CashuMintUrlValidator] **on construction**, before any + * request can be issued, because `token.mint` on a pasted Cashu token is fully + * attacker controlled (SSRF + IP disclosure). Pass [userConfigured] = true only + * when the URL came from somewhere the user deliberately chose it — a mint in + * their own NIP-60 wallet, or one they typed on the CLI — which relaxes the + * host checks (a self-hosted LAN mint is legitimate) but never the scheme check. + * + * @throws MintUrlException when the mint URL is refused. */ class MintHttpClient( mintUrl: String, + userConfigured: Boolean = false, private val okHttpClient: (String) -> OkHttpClient, ) { - private val baseUrl: String = mintUrl.trimEnd('/') + private val baseUrl: String = CashuMintUrlValidator.validatedBaseUrl(mintUrl, userConfigured) // Mint /v1/info changes infrequently (mint name, icon, supported // NUTs, motd). Cache it for [INFO_CACHE_TTL_MS] so the Verify diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt new file mode 100644 index 0000000000..7398dd1d39 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.mintApi + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * SSRF guard for `token.mint`. Every URL below arrives from a pasted/posted + * Cashu token, so it is attacker controlled: the only ones we may ever contact + * are https to a public host, and http to a .onion. + */ +class CashuMintUrlValidatorTest { + private fun allow(url: String) { + val result = CashuMintUrlValidator.validatedBaseUrl(url) + assertEquals(url.trimEnd('/'), result) + } + + private fun reject(url: String) { + assertThrows("expected $url to be refused", MintUrlException::class.java) { + CashuMintUrlValidator.validatedBaseUrl(url) + } + } + + // ------------------------------------------------------------------ + // Allowed + // ------------------------------------------------------------------ + + @Test + fun httpsPublicHostIsAllowed() { + allow("https://mint.minibits.cash/Bitcoin") + allow("https://8333.space:3338") + allow("https://mint.example.com/") + allow("https://8.8.8.8") + } + + @Test + fun onionOverHttpIsAllowed() { + allow("http://cashuxyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs.onion") + allow("http://mint.somewhere.onion:3338/v1") + } + + @Test + fun onionOverHttpsIsAllowed() { + allow("https://cashuxyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs.onion") + } + + // ------------------------------------------------------------------ + // Rejected — scheme + // ------------------------------------------------------------------ + + @Test + fun httpToPublicHostIsRejected() { + reject("http://mint.example.com") + } + + @Test + fun nonHttpSchemesAreRejected() { + reject("file:///etc/passwd") + reject("ftp://mint.example.com") + reject("data://text/plain,hello") + reject("content://com.android.provider/x") + reject("mint.example.com") + reject("") + } + + // ------------------------------------------------------------------ + // Rejected — private / local destinations + // ------------------------------------------------------------------ + + @Test + fun loopbackIsRejected() { + reject("http://127.0.0.1") + reject("https://127.0.0.1:3338") + reject("http://127.0.0.1:8080/v1/info") + reject("https://localhost:3338") + reject("http://[::1]") + reject("https://[::1]:3338") + } + + @Test + fun privateRangesAreRejected() { + reject("http://192.168.1.5") + reject("https://192.168.1.5") + reject("https://10.0.0.7:3338") + reject("https://172.16.4.4") + reject("https://172.31.255.255") + } + + @Test + fun linkLocalAndMetadataEndpointsAreRejected() { + reject("http://169.254.169.254") + reject("https://169.254.169.254/latest/meta-data/") + reject("https://[fe80::1]") + } + + @Test + fun uniqueLocalAndUnspecifiedAreRejected() { + reject("https://[fc00::1]") + reject("https://[fd12:3456:789a::1]") + reject("https://0.0.0.0") + reject("https://[::]") + } + + @Test + fun ipv4MappedIpv6BypassIsRejected() { + reject("https://[::ffff:127.0.0.1]") + reject("https://[::ffff:192.168.1.5]") + reject("https://[::ffff:7f00:1]") + reject("https://[::127.0.0.1]") + } + + @Test + fun inetAtonSpellingsOfLoopbackAreRejected() { + reject("https://2130706433") // decimal 127.0.0.1 + reject("https://0177.0.0.1") // octal + reject("https://0x7f.0.0.1") // hex + reject("https://0x7f000001") + reject("https://127.1") // 2-part form + } + + @Test + fun trailingDotHostnameIsRejected() { + reject("https://localhost.") + reject("https://127.0.0.1.") + } + + @Test + fun userInfoCannotDisguiseTheHost() { + reject("https://mint.example.com@127.0.0.1/v1/info") + reject("https://mint.example.com:pass@192.168.1.5/") + } + + // ------------------------------------------------------------------ + // The user's own wallet mint exception + // ------------------------------------------------------------------ + + @Test + fun privateHostIsAllowedWhenTheUserConfiguredTheMint() { + assertEquals( + "http://192.168.1.5:3338", + CashuMintUrlValidator.validatedBaseUrl("http://192.168.1.5:3338", userConfigured = true), + ) + assertEquals( + "https://127.0.0.1:3338", + CashuMintUrlValidator.validatedBaseUrl("https://127.0.0.1:3338", userConfigured = true), + ) + } + + @Test + fun userConfiguredStillRefusesNonHttpSchemes() { + assertThrows(MintUrlException::class.java) { + CashuMintUrlValidator.validatedBaseUrl("file:///etc/passwd", userConfigured = true) + } + } + + // ------------------------------------------------------------------ + // The client refuses at construction, before any request is issued + // ------------------------------------------------------------------ + + @Test + fun mintHttpClientRefusesAtConstruction() { + assertThrows(MintUrlException::class.java) { + MintHttpClient("http://127.0.0.1:3338") { throw AssertionError("must not build an http client") } + } + } + + @Test + fun mintHttpClientAcceptsPublicHttps() { + MintHttpClient("https://mint.example.com/") { throw AssertionError("no request expected") } + } + + // ------------------------------------------------------------------ + // Parser units — kept so a future "modernization" of the IP parsing + // can't silently reopen a bypass. + // ------------------------------------------------------------------ + + @Test + fun ipv4ParserHandlesInetAtonForms() { + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("127.0.0.1")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("2130706433")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("0177.0.0.1")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("0x7f000001")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("127.1")) + assertEquals(null, CashuMintUrlValidator.parseIpv4("mint.example.com")) + assertEquals(null, CashuMintUrlValidator.parseIpv4("256.0.0.1")) + } + + @Test + fun ipv6ParserHandlesCompressionAndEmbeddedIpv4() { + assertTrue(CashuMintUrlValidator.parseIpv6("::1")!!.let { it[15].toInt() == 1 && it.take(15).all { b -> b.toInt() == 0 } }) + assertEquals(16, CashuMintUrlValidator.parseIpv6("2001:db8::1")!!.size) + assertEquals(16, CashuMintUrlValidator.parseIpv6("::ffff:127.0.0.1")!!.size) + assertEquals(null, CashuMintUrlValidator.parseIpv6("::1::2")) + assertEquals(null, CashuMintUrlValidator.parseIpv6("gggg::1")) + } + + @Test + fun publicIpv6IsAllowed() { + allow("https://[2001:db8::1]") + allow("https://[2606:4700:4700::1111]:3338") + } +}