Preserve port when matching relays against the auth whitelist

The whitelist check used java.net.URI(url).host, which drops the port,
so a "wss://relay.example.com:8080" event tag would not match a
"relay.example.com:8080" whitelist entry. Worse, URI parses a bare
"relay.example.com:8080" with the host as a scheme, so the host was
null and the input fell through to storage with the port — but the
incoming event side stripped it, guaranteeing a mismatch.

Centralize the parsing in RelayUrlUtils.extractHostAndPort, which
prepends "wss://" when no scheme is present and returns "host[:port]".
All six previously duplicated parse sites now share it.
This commit is contained in:
Claude
2026-05-15 10:51:42 +00:00
parent 458cff0c7a
commit 3323d13397
8 changed files with 165 additions and 62 deletions
@@ -13,6 +13,7 @@ import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.permissionTypeFromContent
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -170,13 +171,7 @@ class SignerProvider : ContentProvider() {
// For kind 22242 (NIP-42 relay auth), extract relay host once for both whitelist and permission checks
val relayHost = if (event.kind == 22242) {
AmberEvent.relay(event)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
} else {
""
}
@@ -0,0 +1,29 @@
package com.greenart7c3.nostrsigner.service
import java.net.URI
object RelayUrlUtils {
/**
* Normalizes a relay URL/host into a comparable "host[:port]" form used by
* the auth whitelist and per-relay permission lookups. A bare host:port
* input (e.g. "relay.example.com:8080") would otherwise be parsed by
* java.net.URI as a scheme, so we prepend "wss://" when no scheme is
* present.
*/
fun extractHostAndPort(input: String?): String {
if (input.isNullOrBlank()) return ""
val trimmed = input.trim().trimEnd('/')
return try {
val withScheme = if (trimmed.contains("://")) trimmed else "wss://$trimmed"
val uri = URI(withScheme)
val host = uri.host
when {
host.isNullOrBlank() -> trimmed
uri.port != -1 -> "$host:${uri.port}"
else -> host
}
} catch (e: Exception) {
trimmed
}
}
}
@@ -36,6 +36,7 @@ import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.TrustScoreService
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
@@ -72,11 +73,7 @@ fun AuthWhitelistScreen(
fun addEntry() {
val input = textFieldRelay.value.text.trim()
val url = try {
java.net.URI(input).host ?: input
} catch (e: Exception) {
input
}
val url = RelayUrlUtils.extractHostAndPort(input)
if (url.isNotBlank() && url !in whitelist) {
whitelist.add(url)
Amber.instance.settings = Amber.instance.settings.copy(authWhitelist = whitelist.toList())
@@ -53,6 +53,7 @@ import com.greenart7c3.nostrsigner.service.ApplicationNameCache
import com.greenart7c3.nostrsigner.service.BunkerRequestUtils
import com.greenart7c3.nostrsigner.service.EventNotificationConsumer
import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.RememberType
@@ -260,15 +261,7 @@ fun BunkerMultiEventHomeScreen(
if (relayAuthScope == RelayAuthScope.ALL) {
"*"
} else {
(
AmberEvent.relay(request.request.event)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
)
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(request.request.event))
}
} else {
""
@@ -363,15 +356,7 @@ fun BunkerMultiEventHomeScreen(
if (relayAuthScope == RelayAuthScope.ALL) {
"*"
} else {
(
AmberEvent.relay(localEvent)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
)
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
} else {
""
@@ -37,6 +37,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.toPermissionType
import com.greenart7c3.nostrsigner.service.BunkerRequestUtils
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.isPrivateEvent
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
@@ -662,13 +663,7 @@ fun BunkerSingleEventHomeScreen(
} else if (event.kind == 22242) {
// Kind 22242 = relay client authentication (NIP-42)
// Permission is per-relay hostname extracted from the event's "relay" tag
val relayUrl = AmberEvent.relay(event)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
val relayUrl = RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
// Check for a relay-specific permission first, then wildcard "*" (all relays)
val permission = applicationEntity?.permissions?.firstOrNull {
@@ -54,6 +54,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.theme.orange
@@ -231,15 +232,7 @@ fun IntentMultiEventHomeScreen(
if (relayAuthScope == RelayAuthScope.ALL) {
"*"
} else {
(
AmberEvent.relay(intentData.event)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
)
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(intentData.event))
}
} else {
""
@@ -341,15 +334,7 @@ fun IntentMultiEventHomeScreen(
if (relayAuthScope == RelayAuthScope.ALL) {
"*"
} else {
(
AmberEvent.relay(localEvent)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
)
RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent))
}
} else {
""
@@ -31,6 +31,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.toPermissionType
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.RelayUrlUtils
import com.greenart7c3.nostrsigner.service.isPrivateEvent
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
@@ -257,13 +258,7 @@ fun IntentSingleEventHomeScreen(
} else if (event.kind == 22242) {
// Kind 22242 = relay client authentication (NIP-42)
// Permission is per-relay hostname extracted from the event's "relay" tag
val relayUrl = AmberEvent.relay(event)?.let { url ->
try {
java.net.URI(url).host ?: url
} catch (e: Exception) {
url
}
} ?: ""
val relayUrl = RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event))
// Check for relay-specific permission first, then wildcard "*" (all relays)
val permission = applicationEntity?.permissions?.firstOrNull {
@@ -0,0 +1,122 @@
package com.greenart7c3.nostrsigner.service
import org.junit.Assert.assertEquals
import org.junit.Test
class RelayUrlUtilsTest {
// --- bare host inputs (no scheme) ---
@Test
fun `bare hostname is returned unchanged`() {
assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("relay.example.com"))
}
@Test
fun `bare hostname with port is preserved`() {
// Regression: URI("relay.example.com:8080") treats the host as a scheme,
// so a naive URI.host call returns null and drops the port. The helper
// prepends "wss://" so the port is preserved.
assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("relay.example.com:8080"))
}
@Test
fun `bare IPv4 with port is preserved`() {
assertEquals("127.0.0.1:7777", RelayUrlUtils.extractHostAndPort("127.0.0.1:7777"))
}
// --- inputs with scheme ---
@Test
fun `wss URL without port returns host only`() {
assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("wss://relay.example.com"))
}
@Test
fun `wss URL with port returns host and port`() {
assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080"))
}
@Test
fun `ws URL with port returns host and port`() {
assertEquals("relay.example.com:8443", RelayUrlUtils.extractHostAndPort("ws://relay.example.com:8443"))
}
@Test
fun `https URL with port returns host and port`() {
assertEquals("relay.example.com:443", RelayUrlUtils.extractHostAndPort("https://relay.example.com:443"))
}
@Test
fun `trailing slash is stripped`() {
assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("wss://relay.example.com/"))
}
@Test
fun `trailing slash on host with port is stripped`() {
assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080/"))
}
@Test
fun `surrounding whitespace is trimmed`() {
assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort(" wss://relay.example.com:8080 "))
}
@Test
fun `path after host is ignored`() {
assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080/some/path"))
}
// --- equivalence: the whitelist check (uses `in`) is essentially a string compare ---
@Test
fun `bare host-port and wss URL with same port normalize to same value`() {
// This is the core whitelist regression: a user adds "relay.example.com:8080"
// to the whitelist; the event's "relay" tag is "wss://relay.example.com:8080".
// Both must normalize to the same string for `relayHost in authWhitelist` to pass.
val whitelistEntry = RelayUrlUtils.extractHostAndPort("relay.example.com:8080")
val eventTag = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080")
assertEquals(whitelistEntry, eventTag)
}
@Test
fun `different ports on the same host do not collide`() {
val portA = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080")
val portB = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:9090")
assertEquals("relay.example.com:8080", portA)
assertEquals("relay.example.com:9090", portB)
}
@Test
fun `host with port is distinct from same host without port`() {
val withPort = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080")
val withoutPort = RelayUrlUtils.extractHostAndPort("wss://relay.example.com")
assertEquals("relay.example.com:8080", withPort)
assertEquals("relay.example.com", withoutPort)
}
// --- edge cases ---
@Test
fun `null input returns empty string`() {
assertEquals("", RelayUrlUtils.extractHostAndPort(null))
}
@Test
fun `empty input returns empty string`() {
assertEquals("", RelayUrlUtils.extractHostAndPort(""))
}
@Test
fun `blank input returns empty string`() {
assertEquals("", RelayUrlUtils.extractHostAndPort(" "))
}
@Test
fun `unparseable input falls back to the trimmed original`() {
// Garbage that URI cannot parse should fall through to the trimmed input
// rather than throw.
val weird = "not a valid uri at all <<>>"
assertEquals(weird, RelayUrlUtils.extractHostAndPort(" $weird "))
}
}