mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(cashu): validate a token's mint URL before contacting it
`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:<port>`, 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c84acddc0c
commit
6045e28830
+19
-1
@@ -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<String> = 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),
|
||||
|
||||
+12
-1
@@ -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(
|
||||
|
||||
@@ -2605,6 +2605,8 @@
|
||||
|
||||
<string name="cashu_failed_redemption">Could not redeem Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint provided the following error message: %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">Unsafe Cashu mint address</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">Amethyst did not contact this token\'s mint. %1$s</string>
|
||||
|
||||
<string name="cashu_successful_redemption">Cashu Received</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats were sent to your wallet. (Fees: %2$s sats)</string>
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+317
@@ -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:<port>`, 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<Long>(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<String>? {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val groups = text.split(':')
|
||||
if (groups.any { it.isEmpty() }) return null
|
||||
return groups
|
||||
}
|
||||
|
||||
private fun groupsToBytes(groups: List<String>): ByteArray? {
|
||||
val bytes = ArrayList<Byte>(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'
|
||||
}
|
||||
+11
-1
@@ -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
|
||||
|
||||
+223
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user