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:
Vitor Pamplona
2026-07-19 21:02:59 -04:00
co-authored by Claude Opus 4.8
parent c84acddc0c
commit 6045e28830
8 changed files with 594 additions and 7 deletions
@@ -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),
@@ -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(
+2
View File
@@ -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>
@@ -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) {
@@ -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
@@ -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'
}
@@ -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
@@ -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")
}
}