diff --git a/geode/config.example.toml b/geode/config.example.toml index b64fd4cba..a95b625d5 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -51,7 +51,7 @@ file = "/var/lib/geode/events.db" # recommended for any relay accepting traffic from real clients. # Verify Schnorr signatures on every EVENT. Default: true. Disable # only for trusted-input scenarios (test fixtures, mirror replays). -# verify_signatures = true +verify_signatures = true # Run signature verification in parallel inside the IngestQueue # (across all CPU cores) instead of serially on each connection's @@ -80,13 +80,12 @@ require_auth = false # authenticated with NIP-98 HTTP-Auth. Only events signed by one of # the listed pubkeys can run admin RPCs (banpubkey / banevent / # changerelayname / …). Empty (the default) disables the endpoint. -# pubkeys = ["abcdef...64hex..."] # -# Canonical URL the relay is reachable at, e.g. behind a reverse proxy. -# NIP-98 binds requests to this URL via the `u` tag. **Required** in -# any production deployment — without it, an attacker can spoof the -# Host header to bypass URL binding. -# public_url = "https://relay.example.com/" +# NIP-98 binds admin tokens to the relay's HTTP URL, which is derived +# from [info].relay_url with the scheme swapped (ws -> http, wss -> +# https) per NIP-86. Make sure [info].relay_url is set to the +# canonical public URL when behind TLS termination or a reverse proxy. +# pubkeys = ["abcdef...64hex..."] # Path for the JSON snapshot that persists NIP-86 admin state (ban # lists + the live NIP-11 doc) across restarts. When unset, admin diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index d6a1f1026..8bf4bf970 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -25,18 +25,17 @@ import com.vitorpamplona.geode.server.Nip86HttpRoute import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server -import io.ktor.http.HttpHeaders import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer -import io.ktor.server.request.header import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing @@ -79,20 +78,6 @@ class KtorRelay( * gated by NIP-98 HTTP-Auth membership in this set. */ val adminPubkeys: Set = emptySet(), - /** - * Canonical public URL the relay is reachable at, e.g. - * `https://relay.example.com/`. NIP-98 admin requests must sign - * the **same** URL string they're sending to. When the relay sits - * behind TLS termination or a reverse proxy, the `Host` header - * the relay sees does not match what the client signs, so the - * verifier must compare against this configured value. - * - * `null` (the default) falls back to the request's `Host` header - * with `http://` — fine for local-loopback unit tests, **NOT - * SAFE** in a public deployment because an attacker can spoof - * `Host` and bind their signature to any URL. - */ - val publicUrl: String? = null, /** * Ktor CIO acceptor-thread count. `null` keeps Ktor's default. * Lift on machines with many cores when targeting 10k+ @@ -119,13 +104,25 @@ class KtorRelay( allowList = adminPubkeys, ) + /** + * Always assembled — when [adminPubkeys] is empty, the route + * still runs the canonical NIP-86 flow but every request fails + * the allow-list check ([Nip86HttpHandler.Response.NotAdmin] → + * 403). Uniform code path: the transport doesn't branch on + * "admin enabled?". + * + * The NIP-98 binding URL is derived from `relay.url` via [toHttp] + * because NIP-86 mandates it: admin requests target the same URI + * as the WebSocket endpoint, just with `http(s)://` instead of + * `ws(s)://`. This makes accidental misconfiguration impossible — + * the operator's configured `[info].relay_url` is the single + * source of truth. + */ private val nip86Route = Nip86HttpRoute( - handler = Nip86HttpHandler(server = nip86Server), - signedUrlFor = { call -> - publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) - }, + handler = Nip86HttpHandler(server = nip86Server, publicUrl = relay.url.toHttp()), ) + private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) private var engine: CIOApplicationEngine? = null @@ -190,6 +187,8 @@ class KtorRelay( } // NIP-86: POST application/nostr+json+rpc with a NIP-98 // signed Authorization header → JSON-RPC dispatch. + // Always mounted; an empty [adminPubkeys] just means + // every request fails the allow-list check (403). post(path) { nip86Route.handle(call) } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 20eb3efee..7cc2ec577 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -151,7 +151,6 @@ fun main(args: Array) { port = port, path = path, adminPubkeys = config.admin.pubkeys.toSet(), - publicUrl = config.admin.public_url, connectionGroupSize = config.network.connection_group_size, workerGroupSize = config.network.worker_group_size, callGroupSize = config.network.call_group_size, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 9e3abee84..7b8f2a549 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -202,15 +202,15 @@ data class StaticConfig( * HTTP-Auth. Only requests signed by one of these pubkeys are * dispatched. * - * [public_url] is the canonical URL the relay is reachable at, - * e.g. `https://relay.example.com/`. NIP-98's URL binding compares - * the signed `u` tag against this — without it, an attacker can - * spoof the `Host` header to bind their signature to any URL. - * Required when running behind TLS termination or a reverse proxy. + * The NIP-98 URL binding compares the signed `u` tag against the + * `http(s)://` equivalent of the relay's WebSocket URL — i.e. + * `[info].relay_url` with the scheme swapped (per NIP-86's + * "same URI as `ws(s)://`, called via `http(s)://`"). Set + * `[info].relay_url` to the canonical public URL when running + * behind TLS termination or a reverse proxy. */ data class AdminSection( val pubkeys: List = emptyList(), - val public_url: String? = null, /** * Path for the JSON snapshot that backs [RuntimeConfig] — * NIP-86 admin state (ban lists + the live NIP-11 doc) that diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt index 41c520022..5c3e49d80 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -32,39 +32,33 @@ import io.ktor.utils.io.readAvailable /** * Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by - * [Nip86HttpHandler]. This class is intentionally thin: it pulls the - * Authorization header, signed URL, and body bytes out of the - * [ApplicationCall], hands them to the handler, then maps each - * [Nip86HttpHandler.Response] variant to the Ktor status / header / - * body it expects. + * [Nip86HttpHandler]. Intentionally thin: pull the Authorization + * header and body bytes out of the [ApplicationCall], hand them to + * the handler, map each [Nip86HttpHandler.Response] variant to a + * Ktor status/header/body. * - * The bounded body read happens here (Ktor exposes `ByteReadChannel`, + * Always wired — when the relay's admin allow-list is empty, every + * request just fails the [Nip86HttpHandler.Response.NotAdmin] check + * and returns 403. No "is admin on?" branch in either the handler + * or the route. + * + * Bounded body read happens here (Ktor exposes `ByteReadChannel`, * which is framework-specific) — we stop reading as soon as we'd * exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated * attacker can't OOM the relay with a giant stream. * - * Audit logging also lives here, off [Nip86HttpHandler.Response.Ok] — - * the handler keeps logging policy out of quartz; the geode adapter - * picks a stderr line format and runs with it. + * Audit logging stays here, off [Nip86HttpHandler.Response.Ok] — the + * handler keeps logging policy out of quartz; the geode adapter picks + * a stderr line format and runs with it. */ internal class Nip86HttpRoute( private val handler: Nip86HttpHandler, - private val signedUrlFor: (ApplicationCall) -> String, ) { suspend fun handle(call: ApplicationCall) { - val body = readBoundedBody(call) ?: return // 413 already sent + val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent val authHeader = call.request.header(HttpHeaders.Authorization) - val url = signedUrlFor(call) - - when (val r = handler.handle(authHeader, url, body)) { - Nip86HttpHandler.Response.Disabled -> { - call.respondText( - "NIP-86 management API is not enabled on this relay.", - ContentType.Text.Plain, - HttpStatusCode.Forbidden, - ) - } + when (val r = handler.handle(authHeader, body)) { is Nip86HttpHandler.Response.PayloadTooLarge -> { call.respondText( "request body exceeds ${r.cap}-byte cap", @@ -118,12 +112,14 @@ internal class Nip86HttpRoute( } /** - * Bounded read using `handler.maxBodyBytes`. Returns null after - * sending a 413 if the request body exceeds the cap — either the - * declared `Content-Length` or what we actually pull off the wire. + * Bounded read using [cap]. Returns null after sending a 413 if + * the request body exceeds the cap — either the declared + * `Content-Length` or what we actually pull off the wire. */ - private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? { - val cap = handler.maxBodyBytes + private suspend fun readBoundedBody( + call: ApplicationCall, + cap: Int, + ): ByteArray? { val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() if (declared != null && declared > cap) { call.respondText( diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt index 9ced9c136..12bddfdfa 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -72,13 +72,19 @@ class Nip86EndToEndTest { @BeforeTest fun setup() { - val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() - relay = RelayEngine(url = placeholder) + // KtorRelay derives the NIP-98 admin URL from relay.url (per + // NIP-86: same URI as the WebSocket, just http(s)://). We must + // therefore pre-allocate the port and set relay.url to match + // — placeholder + OS-assigned port would cause the URL the + // server expects to differ from where it actually listens. + val freePort = java.net.ServerSocket(0).use { it.localPort } + val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() + relay = RelayEngine(url = url) server = KtorRelay( relay = relay, host = "127.0.0.1", - port = 0, + port = freePort, adminPubkeys = setOf(admin.pubKey), ).start() scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -196,13 +202,16 @@ class Nip86EndToEndTest { } @Test - fun adminEndpointDisabledWhenNoPubkeysConfigured() = + fun adminEndpointRejectsValidTokenAsNotAdminWhenNoPubkeysConfigured() = runBlocking { - // Spin up a *separate* server with no admin pubkeys. - val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() - val openRelay = RelayEngine(url = placeholder) + // Spin up a *separate* server with no admin pubkeys. The + // POST route is still mounted (uniform code path); every + // request just fails the empty allow-list check → 403. + val freePort = java.net.ServerSocket(0).use { it.localPort } + val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() + val openRelay = RelayEngine(url = url) val openServer = - KtorRelay(openRelay, host = "127.0.0.1", port = 0).start() + KtorRelay(openRelay, host = "127.0.0.1", port = freePort).start() try { val openHttpUrl = openServer.url.replace("ws://", "http://") val body = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt index 9306c4e61..9cc158b8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt @@ -33,31 +33,41 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier * implementation can plug its HTTP framework in without re-deriving * the auth / parse / dispatch / serialize sequence: * - * 1. **Gate.** If `server.isEnabled()` is false (empty admin list), - * reject with [Response.Disabled] (→ 403, "admin API not - * enabled"). No body read, no signature verify. - * 2. **Size cap.** If `body.size > maxBodyBytes`, reject with + * 1. **Size cap.** If `body.size > maxBodyBytes`, reject with * [Response.PayloadTooLarge]. Adapters MUST also bound the read * itself — NIP-98 forces us to compute sha256 over the full * body for signature binding, so an unbounded read is a pre-auth * OOM vector. This check is defense-in-depth. - * 3. **Verify.** Run [Nip98AuthVerifier.verify] (method = `POST`, - * given [url] and [body]). Missing → [Response.MissingAuth] + * 2. **Verify.** Run [Nip98AuthVerifier.verify] with method = `POST` + * and the handler's configured [publicUrl]. The NIP-98 token's + * signed `u` tag MUST match [publicUrl] — that's how the relay + * proves the admin token was minted for *this* relay and not + * replayed from another one. Missing header → [Response.MissingAuth] * (→ 401 + `WWW-Authenticate: Nostr`). Malformed → * [Response.BadAuth] (→ 401). - * 4. **Admin check.** If the verified pubkey is not in + * 3. **Admin check.** If the verified pubkey is not in * `server.isAuthorized`, reject with [Response.NotAdmin] (→ 403). - * 5. **Parse.** Decode [Nip86Request] from the body bytes. Invalid + * 4. **Parse.** Decode [Nip86Request] from the body bytes. Invalid * → [Response.BadRequest] (→ 400). - * 6. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the + * 5. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the * [Nip86Response] in [Response.Ok], pre-serialized as JSON ready * to write to the wire with `Content-Type: application/nostr+json+rpc`. * - * Transport-agnostic: takes raw primitives ([authHeader], [url], - * [body]) and returns a sealed [Response]. The adapter maps each - * variant to its framework's status-code / header API — for Ktor, - * see `geode/server/Nip86HttpRoute`. + * An empty admin allow-list is not a special case: the handler runs + * the same flow, the pubkey check (step 3) just always fails with + * [Response.NotAdmin]. Transports therefore wire the route the same + * way regardless of whether admin happens to be enabled — uniform + * code path, uniform error model. * + * Transport-agnostic: takes raw primitives ([authHeader], [body]) + * and returns a sealed [Response]. The adapter maps each variant to + * its framework's status-code / header API — for Ktor, see + * `geode/server/Nip86HttpRoute`. + * + * @param publicUrl Canonical URL admin tokens must sign for — + * typically `https://relay.example.com/`. Required (non-blank) + * precisely because the alternative ("trust the `Host` header") + * lets an attacker bind their signed admin token to any URL. * @param maxBodyBytes Defense-in-depth cap. NIP-86 RPC payloads are * a few hundred bytes; the 1 MiB default is ~1000× any plausible * request, but small enough that an attacker can't OOM the relay @@ -65,19 +75,22 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier */ class Nip86HttpHandler( private val server: Nip86Server, + private val publicUrl: String, private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(), val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES, ) { + init { + require(publicUrl.isNotBlank()) { "publicUrl must not be blank" } + } + suspend fun handle( authHeader: String?, - url: String, body: ByteArray, ): Response { - if (!server.isEnabled()) return Response.Disabled if (body.size > maxBodyBytes) return Response.PayloadTooLarge(maxBodyBytes) val pubkey = - when (val v = verifier.verify(authHeader, method = "POST", url = url, body = body)) { + when (val v = verifier.verify(authHeader, method = "POST", url = publicUrl, body = body)) { is Nip98AuthVerifier.Result.Verified -> v.pubkey Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason) @@ -102,17 +115,17 @@ class Nip86HttpHandler( * * | Variant | HTTP | Notes | * |---|---|---| - * | [Disabled] | 403 | "admin API not enabled" | * | [PayloadTooLarge] | 413 | adapter SHOULD bound the read itself | * | [MissingAuth] | 401 | send `WWW-Authenticate: Nostr` | - * | [BadAuth] | 401 | NIP-98 signature/binding failed | + * | [BadAuth] | 401 | NIP-98 signature/binding failed (incl. URL mismatch) | * | [NotAdmin] | 403 | verified pubkey not on allow-list | * | [BadRequest] | 400 | body wasn't a valid `Nip86Request` | * | [Ok] | 200 | `Content-Type: application/nostr+json+rpc`; write [Ok.json] | + * + * "Admin endpoint disabled" is **not** a [Response] variant — the + * adapter must check that case before instantiating a handler. */ sealed interface Response { - data object Disabled : Response - data class PayloadTooLarge( val cap: Int, ) : Response diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index a8e6afd15..9e8ce452e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -54,10 +54,9 @@ import kotlinx.serialization.json.int * whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`), * and in-process tests can build a [Nip86Request] directly. The * [allowList] check is enforced inside [dispatch] so no caller can - * accidentally bypass it; transports also have [isEnabled] and - * [isAuthorized] to distinguish "endpoint disabled" (empty list, e.g. - * 403 + "not enabled") from "not allowed" (valid token but - * unrecognized pubkey, e.g. 403 + "not on admin list"). + * accidentally bypass it; transports may also use [isAuthorized] to + * make the decision before dispatching (e.g. to short-circuit the + * request parse). * * [supportedMethods] is the canonical list this server actually * implements; methods returned outside of it are no-ops and a NIP-86 @@ -91,17 +90,15 @@ class Nip86Server( */ private val onBan: suspend (Filter) -> Unit = {}, /** - * Pubkeys allowed to invoke admin RPCs. Empty disables the admin - * API entirely — [isEnabled] returns false and [dispatch] rejects - * everything. Compared case-insensitively (lowercased on entry). + * Pubkeys allowed to invoke admin RPCs. Empty effectively disables + * the admin API: [isAuthorized] returns false for every pubkey + * and [dispatch] rejects everything as `not authorized`. Compared + * case-insensitively (lowercased on entry). */ allowList: Set = emptySet(), ) { private val allowList: Set = allowList.mapTo(HashSet()) { it.lowercase() } - /** True when at least one admin pubkey is configured. */ - fun isEnabled(): Boolean = allowList.isNotEmpty() - /** True when [pubkey] is on the admin allow-list. Case-insensitive. */ fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList @@ -138,10 +135,9 @@ class Nip86Server( * WS, or in-process trust). * * If [pubkey] is not in [allowList], returns a `not authorized` - * error response without executing anything. Transports that - * surface different HTTP statuses for "disabled" vs "not on list" - * should pre-check via [isEnabled] / [isAuthorized] instead of - * relying on this string. + * error response without executing anything. Transports that want + * to short-circuit before parsing the request can pre-check via + * [isAuthorized]. */ suspend fun dispatch( pubkey: HexKey, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt index 5ef6ede04..8c20ece85 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -41,9 +42,9 @@ class Nip86HttpHandlerTest { private val verifier = Nip98AuthVerifier(now = { now }) private val adminSigner = NostrSignerSync(KeyPair()) private val intruderSigner = NostrSignerSync(KeyPair()) - private val url = "http://relay.example.com/" + private val publicUrl = "https://relay.example.com/" - private fun handlerWith(adminInList: Boolean = true): Pair { + private fun handler(): Nip86HttpHandler { val server = Nip86Server( banStore = BanStore(), @@ -57,13 +58,15 @@ class Nip86HttpHandlerTest { doc = info } }, - allowList = if (adminInList) setOf(adminSigner.pubKey) else emptySet(), + allowList = setOf(adminSigner.pubKey), ) - return Nip86HttpHandler(server, verifier) to server + return Nip86HttpHandler(server, publicUrl, verifier) } + /** Build a NIP-98 token for [url] (which may or may not match [publicUrl]). */ private fun signedHeader( body: ByteArray, + url: String = publicUrl, signer: NostrSignerSync = adminSigner, ): String { val template = HTTPAuthorizationEvent.build(url = url, method = "POST", file = body, createdAt = now) @@ -74,32 +77,72 @@ class Nip86HttpHandlerTest { get() = JsonMapper.toJson(Nip86Request(method = Nip86Method.SUPPORTED_METHODS)).encodeToByteArray() @Test - fun disabledWhenAllowListIsEmpty() { + fun emptyAllowListRejectsValidlySignedRequestsAsNotAdmin() { + // No special "disabled" handling — the handler runs the same + // flow and the allow-list check (which always fails for an + // empty list) returns NotAdmin. runBlocking { - val (handler, _) = handlerWith(adminInList = false) + val disabledServer = + Nip86Server( + banStore = BanStore(), + infoHolder = + object : Nip86Server.InfoHolder { + private var doc = Nip11RelayInformation() + + override fun get() = doc + + override fun set(info: Nip11RelayInformation) { + doc = info + } + }, + // no allowList + ) + val h = Nip86HttpHandler(disabledServer, publicUrl, verifier) val body = supportedMethodsBody - val r = handler.handle(signedHeader(body), url, body) - assertIs(r) + val header = signedHeader(body) // signed by adminSigner — but list is empty + val r = h.handle(header, body) + assertIs(r) + } + } + + @Test + fun rejectsConstructionWithBlankPublicUrl() { + val server = + Nip86Server( + banStore = BanStore(), + infoHolder = + object : Nip86Server.InfoHolder { + private var doc = Nip11RelayInformation() + + override fun get() = doc + + override fun set(info: Nip11RelayInformation) { + doc = info + } + }, + allowList = setOf(adminSigner.pubKey), + ) + assertFailsWith { + Nip86HttpHandler(server, publicUrl = " ") } } @Test fun payloadTooLargeBeforeAuthCheck() { runBlocking { - val (handler, _) = handlerWith() - val oversized = ByteArray(handler.maxBodyBytes + 1) + val h = handler() + val oversized = ByteArray(h.maxBodyBytes + 1) // No need for a valid signature — size check fires first. - val r = handler.handle("anything", url, oversized) + val r = h.handle("anything", oversized) assertIs(r) - assertEquals(handler.maxBodyBytes, r.cap) + assertEquals(h.maxBodyBytes, r.cap) } } @Test fun missingAuthHeader() { runBlocking { - val (handler, _) = handlerWith() - val r = handler.handle(authHeader = null, url = url, body = supportedMethodsBody) + val r = handler().handle(authHeader = null, body = supportedMethodsBody) assertIs(r) } } @@ -107,20 +150,32 @@ class Nip86HttpHandlerTest { @Test fun malformedAuthIsBadAuth() { runBlocking { - val (handler, _) = handlerWith() - val r = handler.handle("Bearer not-a-nostr-token", url, supportedMethodsBody) + val r = handler().handle("Bearer not-a-nostr-token", supportedMethodsBody) assertIs(r) assertTrue(r.reason.contains("Nostr")) } } + @Test + fun urlMismatchInTokenIsBadAuth() { + // Token signed for a different URL — the URL-binding check + // fires and the relay refuses. This is the attack the + // publicUrl requirement is closing off. + runBlocking { + val body = supportedMethodsBody + val header = signedHeader(body, url = "https://other-relay.example.com/") + val r = handler().handle(header, body) + assertIs(r) + assertTrue(r.reason.contains("url mismatch")) + } + } + @Test fun verifiedButNotAdminIsRejectedAsNotAdmin() { runBlocking { - val (handler, _) = handlerWith() // admin is adminSigner only val body = supportedMethodsBody val header = signedHeader(body, signer = intruderSigner) - val r = handler.handle(header, url, body) + val r = handler().handle(header, body) assertIs(r) } } @@ -128,10 +183,9 @@ class Nip86HttpHandlerTest { @Test fun verifiedAdminButBadJsonBodyIsBadRequest() { runBlocking { - val (handler, _) = handlerWith() val body = "not valid json {".encodeToByteArray() val header = signedHeader(body) - val r = handler.handle(header, url, body) + val r = handler().handle(header, body) assertIs(r) } } @@ -139,10 +193,9 @@ class Nip86HttpHandlerTest { @Test fun verifiedAdminWithValidRequestDispatches() { runBlocking { - val (handler, _) = handlerWith() val body = supportedMethodsBody val header = signedHeader(body) - val r = handler.handle(header, url, body) + val r = handler().handle(header, body) val ok = assertIs(r) assertEquals(adminSigner.pubKey, ok.pubkey) assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index e054122ed..534eb84b4 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -188,7 +188,7 @@ class Nip86ServerTest { val (server, _, _) = fixture() val resp = server.dispatch(admin, Nip86Request(method = "frobnicate")) assertNotNull(resp.error) - assertTrue(resp.error!!.contains("frobnicate")) + assertTrue(resp.error.contains("frobnicate")) } } @@ -199,7 +199,7 @@ class Nip86ServerTest { // banpubkey requires at least one positional param. val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY)) assertNotNull(resp.error) - assertTrue(resp.error!!.startsWith("invalid params")) + assertTrue(resp.error.startsWith("invalid params")) } } @@ -210,18 +210,17 @@ class Nip86ServerTest { val intruder = "e".repeat(64) val resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam")) assertNotNull(resp.error) - assertTrue(resp.error!!.contains("not on the admin list")) + assertTrue(resp.error.contains("not on the admin list")) // And no state was mutated. assertTrue(!banStore.isBanned(pk)) } } @Test - fun emptyAllowListDisablesTheServer() { + fun emptyAllowListRejectsEveryPubkey() { val store = BanStore() val holder = Holder(Nip11RelayInformation(name = "x")) val server = Nip86Server(banStore = store, infoHolder = holder) // no allowList - assertEquals(false, server.isEnabled()) assertEquals(false, server.isAuthorized(admin)) }