refactor(quartz, geode): derive NIP-86 publicUrl from relay.url; drop isEnabled branch

Two simplifications:

1. Derive admin URL from RelayEngine.url. NIP-86 spec mandates that
   the admin endpoint is "the same URI as ws(s)://, called via
   http(s)://" — so KtorRelay derives the NIP-98 binding URL by
   calling relay.url.toHttp() instead of accepting publicUrl as a
   separate config. Single source of truth (info.relay_url),
   accidental misconfiguration impossible, no Host-header fallback.
   StaticConfig.AdminSection.public_url is gone.

2. Uniform code path for admin-enabled vs disabled. Empty allow-list
   isn't a special case anywhere: Nip86Server.isAuthorized returns
   false for everyone, dispatch rejects, Nip86HttpHandler returns
   NotAdmin → 403. KtorRelay always assembles the Nip86HttpRoute
   and registers the POST endpoint; Nip86Server.isEnabled() and the
   "is admin on?" branches in the handler and route are deleted.

Nip86EndToEndTest's admin-disabled case is now a behavioral test:
sign a valid token, expect 403 NotAdmin (was 405 with the no-route
variant, was 403 with the fake-disabled-route variant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 18:19:24 -04:00
parent a53b3339b3
commit 445fbf8cda
10 changed files with 192 additions and 129 deletions
+6 -7
View File
@@ -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
@@ -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<HexKey> = 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)
}
@@ -151,7 +151,6 @@ fun main(args: Array<String>) {
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,
@@ -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<String> = 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
@@ -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(
@@ -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 =
@@ -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
@@ -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<HexKey> = emptySet(),
) {
private val allowList: Set<HexKey> = 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,
@@ -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<Nip86HttpHandler, Nip86Server> {
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<Nip86HttpHandler.Response.Disabled>(r)
val header = signedHeader(body) // signed by adminSigner — but list is empty
val r = h.handle(header, body)
assertIs<Nip86HttpHandler.Response.NotAdmin>(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<IllegalArgumentException> {
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<Nip86HttpHandler.Response.PayloadTooLarge>(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<Nip86HttpHandler.Response.MissingAuth>(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<Nip86HttpHandler.Response.BadAuth>(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<Nip86HttpHandler.Response.BadAuth>(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<Nip86HttpHandler.Response.NotAdmin>(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<Nip86HttpHandler.Response.BadRequest>(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<Nip86HttpHandler.Response.Ok>(r)
assertEquals(adminSigner.pubKey, ok.pubkey)
assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method)
@@ -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))
}