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:
@@ -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
-20
@@ -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
|
||||
|
||||
+10
-14
@@ -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,
|
||||
|
||||
+75
-22
@@ -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)
|
||||
|
||||
+4
-5
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user