feat(relay): enforce [limits] + [authorization] config sections

Wires the parsed-but-unenforced config sections into actual relay
behavior via five new IRelayPolicy implementations under
quartz-relay/.../policies/. They compose through the existing
PolicyStack so operators stack only what they need; cheap rejection
paths run before expensive ones (rate-limit → AUTH → future/size/lists
→ signature verification).

New policies:
  - KindAllowDenyPolicy           — kind_whitelist + kind_blacklist
  - PubkeyAllowDenyPolicy         — pubkey_whitelist + pubkey_blacklist
                                    (case-insensitive)
  - RejectFutureEventsPolicy      — options.reject_future_seconds
  - MaxEventBytesPolicy           — limits.max_event_bytes
                                    (size of canonical NIP-01 JSON form)
  - RateLimitPolicy               — token-bucket per session for
                                    messages_per_sec + subscriptions_per_min;
                                    monotonic clock so wall-time jumps
                                    don't reset buckets.

Plus:
  - LocalRelayServer now honors limits.max_ws_frame_bytes /
    limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize.
  - Main.kt builds the policy stack from RelayConfig in composePolicy().
    Warning surface is reduced to only the three sections still
    pending (max_subscriptions_per_session, max_filters_per_req,
    network.remote_ip_header).
  - PassThroughPolicy base lets each policy declare only the hook(s)
    it actually enforces, keeping call sites readable.

Tests (20 new):
  - PoliciesTest (16) — per-policy unit coverage for each accept/reject
    boundary, including allow + deny precedence, case-insensitive pubkey
    matching, deterministic rate-limit refill via injected clock, and
    composition via IRelayPolicy.plus.
  - PoliciesIntegrationTest (4) — end-to-end through NostrClient →
    RelayHub → Relay; proves OK false comes back over the wire when
    policies reject (kind blacklist, pubkey allow-list, future
    timestamps, oversize events).

Total :quartz-relay tests: 62, 0 failures.

Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the
previous margin was thin enough to flake on busy CI when the SQLite
unixepoch() rounds down across the wait.
This commit is contained in:
Claude
2026-05-07 02:14:42 +00:00
parent cbe5dbe07b
commit 633d0c3c74
12 changed files with 898 additions and 37 deletions
+15 -7
View File
@@ -46,21 +46,29 @@ require_auth = false
# future. Parsed today, enforced once the matching policy lands. # future. Parsed today, enforced once the matching policy lands.
# reject_future_seconds = 1800 # reject_future_seconds = 1800
# --- Sections below are parsed today but NOT YET ENFORCED. They are
# accepted for forward compatibility — the matching enforcement code is
# tracked separately. The relay logs a warning for each used section. ---
[limits] [limits]
# Maximum byte size of an EVENT (canonical NIP-01 JSON form).
# Enforced by MaxEventBytesPolicy.
# max_event_bytes = 131072 # max_event_bytes = 131072
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576 # max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576 # max_ws_frame_bytes = 1048576
# Per-session token-bucket caps. Enforced by RateLimitPolicy.
# messages_per_sec = 10 # messages_per_sec = 10
# subscriptions_per_min = 60 # subscriptions_per_min = 60
# Parsed but NOT YET ENFORCED.
# max_subscriptions_per_session = 32 # max_subscriptions_per_session = 32
# max_filters_per_req = 10 # max_filters_per_req = 10
[authorization] [authorization]
# pubkey_whitelist = [] # Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
# pubkey_whitelist = ["abcdef...64hex..."]
# pubkey_blacklist = [] # pubkey_blacklist = []
# kind_whitelist = [] # kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [] # kind_blacklist = [4]
@@ -62,6 +62,13 @@ class LocalRelayServer(
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */ /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
val port: Int = 0, val port: Int = 0,
val path: String = "/", val path: String = "/",
/**
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
* config. Frames larger than this are rejected at the WebSocket
* layer, which is the only layer that sees the raw bytes. `null`
* uses Ktor's default (~1 MiB).
*/
val maxFrameBytes: Long? = null,
) { ) {
private var engine: CIOApplicationEngine? = null private var engine: CIOApplicationEngine? = null
private var resolvedPort: Int = -1 private var resolvedPort: Int = -1
@@ -80,7 +87,9 @@ class LocalRelayServer(
fun start(): LocalRelayServer { fun start(): LocalRelayServer {
val server = val server =
embeddedServer(CIO, host = host, port = port) { embeddedServer(CIO, host = host, port = port) {
install(WebSockets) install(WebSockets) {
maxFrameBytes?.let { maxFrameSize = it }
}
routing { routing {
// NIP-11: GET on the relay URL with Accept: // NIP-11: GET on the relay URL with Accept:
// application/nostr+json returns the relay info doc. // application/nostr+json returns the relay info doc.
@@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.relay.config.RelayConfig import com.vitorpamplona.quartz.relay.config.RelayConfig
import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.relay.policies.MaxEventBytesPolicy
import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.relay.policies.RateLimitPolicy
import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy
import java.io.File import java.io.File
/** /**
@@ -89,18 +94,26 @@ fun main(args: Array<String>) {
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
val policyBuilder: () -> IRelayPolicy = val policyBuilder: () -> IRelayPolicy = {
when { composePolicy(config, advertisedUrl, requireAuth, verifySigs)
verifySigs && requireAuth -> { -> VerifyPolicy + FullAuthPolicy(advertisedUrl) } }
verifySigs -> { -> VerifyPolicy }
requireAuth -> { -> FullAuthPolicy(advertisedUrl) }
else -> { -> EmptyPolicy }
}
warnUnenforcedSections(config) warnUnenforcedSections(config)
val relay = Relay(advertisedUrl, store, info, policyBuilder) val relay = Relay(advertisedUrl, store, info, policyBuilder)
val server = LocalRelayServer(relay, host = host, port = port, path = path).start() // Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
// a single per-frame limit; multi-frame messages remain unbounded).
val frameLimit =
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
val server =
LocalRelayServer(
relay,
host = host,
port = port,
path = path,
maxFrameBytes = frameLimit,
).start()
Runtime.getRuntime().addShutdownHook( Runtime.getRuntime().addShutdownHook(
Thread { Thread {
@@ -116,30 +129,72 @@ fun main(args: Array<String>) {
Thread.currentThread().join() Thread.currentThread().join()
} }
/** Surface a warning when the operator has set sections we don't yet enforce. */ /**
* Builds the policy stack for one connection from the config.
*
* Order matters — cheap rejection paths run before expensive ones:
* 1. Rate limit (per-session, fastest reject path)
* 2. AUTH (drops everything if not authenticated)
* 3. Future-timestamp + size-cap + allow/deny lists
* 4. Signature verification (most expensive)
*
* The relay's `policyBuilder` factory is invoked per connection so
* rate-limit token buckets are session-scoped (not global).
*/
private fun composePolicy(
config: RelayConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
requireAuth: Boolean,
verifySigs: Boolean,
): IRelayPolicy {
val pieces = mutableListOf<IRelayPolicy>()
val l = config.limits
if (l.messages_per_sec != null || l.subscriptions_per_min != null) {
pieces += RateLimitPolicy(l.messages_per_sec, l.subscriptions_per_min)
}
if (requireAuth) {
pieces += FullAuthPolicy(advertisedUrl)
}
config.options.reject_future_seconds?.let { secs ->
pieces += RejectFutureEventsPolicy(secs)
}
l.max_event_bytes?.let { bytes ->
pieces += MaxEventBytesPolicy(bytes)
}
val auth = config.authorization
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
}
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
}
if (verifySigs) {
pieces += VerifyPolicy
}
return pieces.fold<IRelayPolicy, IRelayPolicy>(EmptyPolicy) { acc, p ->
if (acc === EmptyPolicy) p else acc + p
}
}
/**
* Surface a warning for config sections we still don't enforce. As we
* add policies the matching branch is removed here.
*/
private fun warnUnenforcedSections(config: RelayConfig) { private fun warnUnenforcedSections(config: RelayConfig) {
val warnings = mutableListOf<String>() val warnings = mutableListOf<String>()
val l = config.limits val l = config.limits
if (l.max_event_bytes != null || if (l.max_subscriptions_per_session != null) {
l.max_ws_message_bytes != null || warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED."
l.max_ws_frame_bytes != null ||
l.messages_per_sec != null ||
l.subscriptions_per_min != null ||
l.max_subscriptions_per_session != null ||
l.max_filters_per_req != null
) {
warnings += "[limits] section is parsed but NOT YET ENFORCED — rate limits / message size caps are pending."
} }
val auth = config.authorization if (l.max_filters_per_req != null) {
if (auth.pubkey_whitelist.isNotEmpty() || warnings += "[limits].max_filters_per_req is parsed but NOT YET ENFORCED."
auth.pubkey_blacklist.isNotEmpty() ||
auth.kind_whitelist.isNotEmpty() ||
auth.kind_blacklist.isNotEmpty()
) {
warnings += "[authorization] section is parsed but NOT YET ENFORCED — pubkey/kind allow-deny lists are pending."
}
if (config.options.reject_future_seconds != null) {
warnings += "[options].reject_future_seconds is parsed but NOT YET ENFORCED."
} }
if (config.network.remote_ip_header != null) { if (config.network.remote_ip_header != null) {
warnings += "[network].remote_ip_header is parsed but NOT YET ENFORCED — IP-based limits are pending." warnings += "[network].remote_ip_header is parsed but NOT YET ENFORCED — IP-based limits are pending."
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].kind_whitelist` / `kind_blacklist`.
*
* - When [allow] is non-empty, only events whose [Event.kind] is in
* [allow] are accepted; everything else gets `blocked: kind X not allowed`.
* - When [deny] is non-empty, events whose kind is in [deny] are
* rejected with `blocked: kind X denied`.
* - Both lists may be empty (no-op pass-through).
* - When both are set, allow is checked first (deny inside allow is
* still denied, matching nostr-rs-relay's precedence).
*/
class KindAllowDenyPolicy(
val allow: Set<Int> = emptySet(),
val deny: Set<Int> = emptySet(),
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val k = cmd.event.kind
if (allow.isNotEmpty() && k !in allow) {
return PolicyResult.Rejected("blocked: kind $k not allowed")
}
if (k in deny) {
return PolicyResult.Rejected("blocked: kind $k denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Rejects events whose canonical JSON byte size exceeds [maxBytes].
* Mirrors nostr-rs-relay's `[limits].max_event_bytes`.
*
* Note: this measures the size of the SERVER-side re-serialised event
* (via [OptimizedJsonMapper.toJson]), which is byte-equivalent to the
* canonical NIP-01 form a well-behaved client would have sent. It does
* NOT enforce `[limits].max_ws_message_bytes` — that one belongs at the
* WebSocket frame layer (Ktor `WebSockets { maxFrameSize = ... }` for
* [com.vitorpamplona.quartz.relay.LocalRelayServer]) because the policy
* layer never sees the raw frame. Both limits are enforced together
* when the operator sets them in the config.
*/
class MaxEventBytesPolicy(
val maxBytes: Int,
) : PassThroughPolicy() {
init {
require(maxBytes > 0) { "maxBytes must be > 0, got $maxBytes" }
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val size = OptimizedJsonMapper.toJson(cmd.event).length
return if (size > maxBytes) {
PolicyResult.Rejected("invalid: event size $size exceeds limit of $maxBytes bytes")
} else {
PolicyResult.Accepted(cmd)
}
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Convenience base that accepts everything by default. Subclasses
* override only the hook(s) they actually enforce so the call sites
* stay readable.
*/
abstract class PassThroughPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) {}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd): PolicyResult<AuthCmd> = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event): Boolean = true
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].pubkey_whitelist` / `pubkey_blacklist`.
*
* - [allow] non-empty: only events from listed pubkeys are accepted.
* This is the "private relay" mode.
* - [deny] non-empty: events from listed pubkeys are rejected.
* - Empty lists are no-op pass-through.
* - When both are set, allow is checked first.
*
* Pubkeys are matched case-insensitively (lowercased on entry).
*/
class PubkeyAllowDenyPolicy(
allow: Set<HexKey> = emptySet(),
deny: Set<HexKey> = emptySet(),
) : PassThroughPolicy() {
private val allow = allow.mapTo(HashSet()) { it.lowercase() }
private val deny = deny.mapTo(HashSet()) { it.lowercase() }
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val pk = cmd.event.pubKey.lowercase()
if (allow.isNotEmpty() && pk !in allow) {
return PolicyResult.Rejected("blocked: pubkey not on allow list")
}
if (pk in deny) {
return PolicyResult.Rejected("blocked: pubkey is denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Per-session token-bucket rate limiter. Mirrors nostr-rs-relay's
* `[limits].messages_per_sec` and `[limits].subscriptions_per_min`.
*
* - [messagesPerSec] caps EVERY incoming command (EVENT/REQ/COUNT/AUTH)
* over a 1-second window. `null` disables.
* - [subscriptionsPerMin] caps REQ + COUNT (subscriptions opened) over
* a 60-second window. `null` disables.
*
* Each session gets its own buckets — instances of this policy must be
* created per-connection via the relay's `policyBuilder` factory.
*
* Time source defaults to monotonic [System.nanoTime] so wall-clock
* jumps don't reset the buckets. Tests inject a deterministic clock.
*/
class RateLimitPolicy(
val messagesPerSec: Int? = null,
val subscriptionsPerMin: Int? = null,
private val nowNanos: () -> Long = System::nanoTime,
) : PassThroughPolicy() {
private val msgBucket =
messagesPerSec?.let {
require(it > 0) { "messagesPerSec must be > 0" }
TokenBucket(capacity = it, refillIntervalNanos = 1_000_000_000L / it, nowNanos)
}
private val subBucket =
subscriptionsPerMin?.let {
require(it > 0) { "subscriptionsPerMin must be > 0" }
TokenBucket(capacity = it, refillIntervalNanos = 60_000_000_000L / it, nowNanos)
}
private fun checkMsgBucket(): String? = if (msgBucket?.tryTake() == false) "blocked: too many messages per second" else null
private fun checkSubBucket(): String? = if (subBucket?.tryTake() == false) "blocked: too many subscriptions per minute" else null
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
return PolicyResult.Accepted(cmd)
}
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> {
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
checkSubBucket()?.let { return PolicyResult.Rejected(it) }
return PolicyResult.Accepted(cmd)
}
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> {
checkMsgBucket()?.let { return PolicyResult.Rejected(it) }
checkSubBucket()?.let { return PolicyResult.Rejected(it) }
return PolicyResult.Accepted(cmd)
}
}
/**
* Token bucket with monotonic refill. Single-threaded by contract —
* RelaySession.receive runs serially within a session, so no locking
* is needed.
*/
private class TokenBucket(
val capacity: Int,
val refillIntervalNanos: Long,
val now: () -> Long,
) {
private var tokens: Long = capacity.toLong()
private var lastRefill: Long = now()
fun tryTake(): Boolean {
refill()
return if (tokens > 0) {
tokens -= 1
true
} else {
false
}
}
private fun refill() {
val n = now()
val elapsed = n - lastRefill
if (elapsed <= 0) return
val newTokens = elapsed / refillIntervalNanos
if (newTokens > 0) {
tokens = (tokens + newTokens).coerceAtMost(capacity.toLong())
lastRefill += newTokens * refillIntervalNanos
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Rejects events whose `created_at` is more than [maxFutureSeconds]
* seconds in the future relative to the relay's clock. Mirrors
* nostr-rs-relay's `[options].reject_future_seconds`.
*
* This catches both clock-skew accidents and intentional far-future
* timestamps used to push events to the top of newest-first feeds.
*
* The current time is read from [TimeUtils.now] (epoch seconds), the
* same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired]
* check uses, so the relay's "future" and "expired" decisions agree.
*/
class RejectFutureEventsPolicy(
val maxFutureSeconds: Int,
private val now: () -> Long = { TimeUtils.now() },
) : PassThroughPolicy() {
init {
require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" }
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val skew = cmd.event.createdAt - now()
return if (skew > maxFutureSeconds) {
PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)")
} else {
PolicyResult.Accepted(cmd)
}
}
}
@@ -145,7 +145,10 @@ class Nip40ExpirationTest {
) )
// Wait until shortLived is past its expiration, then sweep. // Wait until shortLived is past its expiration, then sweep.
kotlinx.coroutines.delay(1500) // SQLite's unixepoch() is integer seconds, so we need a full
// second's gap from the (now + 1) expiration; bump to 2.5s
// to absorb thread-scheduling jitter on busy CI runners.
kotlinx.coroutines.delay(2500)
hub.getOrCreate(relayUrl).store.deleteExpiredEvents() hub.getOrCreate(relayUrl).store.deleteExpiredEvents()
// Long-lived survives. // Long-lived survives.
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.relay.RelayHub
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* End-to-end through `NostrClient → RelayHub → Relay` with the policies
* actually wired into the relay. Proves an EVENT command sent on the
* wire surfaces an OK false response when the policy rejects.
*/
class PoliciesIntegrationTest {
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
private lateinit var scope: CoroutineScope
@BeforeTest
fun setup() {
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
@AfterTest
fun teardown() {
scope.cancel()
}
/** Spin up a hub whose only relay uses the supplied policy factory. */
private fun hubWith(policyFactory: () -> IRelayPolicy): Pair<NostrClient, RelayHub> {
val hub = RelayHub(defaultPolicy = policyFactory)
// Materialise the relay so the URL resolves in the hub.
hub.getOrCreate(relayUrl)
return NostrClient(hub, scope) to hub
}
@Test
fun kindBlacklistRejectsKind4OverWire() =
runBlocking {
val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) }
try {
val signer = NostrSignerSync(KeyPair())
val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl))
assertEquals(true, ok, "kind 1 must pass")
// Synthetic kind-4 event — the relay's deny list rejects it.
val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey)
val rejected = client.publishAndConfirm(kind4, setOf(relayUrl))
assertEquals(false, rejected, "kind 4 must be rejected")
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun pubkeyAllowListRejectsForeignAuthorOverWire() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val mallory = NostrSignerSync(KeyPair())
val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) }
try {
val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl))
assertEquals(true, accepted)
val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl))
assertEquals(false, denied)
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() =
runBlocking {
// Use a fixed clock so the policy decision is deterministic.
val frozen = 1_000_000L
val (client, hub) =
hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) }
try {
val signer = NostrSignerSync(KeyPair())
val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30))
assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl)))
val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600))
assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl)))
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun maxEventBytesBlocksOversizeOverWire() =
runBlocking {
val (client, hub) = hubWith { MaxEventBytesPolicy(maxBytes = 400) }
try {
val signer = NostrSignerSync(KeyPair())
val small = signer.sign(TextNoteEvent.build("hi"))
assertEquals(true, client.publishAndConfirm(small, setOf(relayUrl)))
val huge = signer.sign(TextNoteEvent.build("x".repeat(2_000)))
assertEquals(false, client.publishAndConfirm(huge, setOf(relayUrl)))
} finally {
client.disconnect()
hub.close()
}
}
}
@@ -0,0 +1,264 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Per-policy unit tests. Each policy gets a small, focused suite that
* proves accept/reject behaviour at the boundaries (empty config,
* single hit, collision between allow + deny, etc.).
*
* The end-to-end "policy is applied through the Ktor server" coverage
* lives in `LocalRelayServerTest` / `Nip01ComplianceTest` — these tests
* just exercise the policy in isolation.
*/
class PoliciesTest {
private fun event(
kind: Int = 1,
pubKey: String = SyntheticEvents.hexId(1),
createdAt: Long = 1000L,
content: String = "",
) = SyntheticEvents.fakeEvent(idSeed = 1, kind = kind, pubKey = pubKey, createdAt = createdAt, content = content)
private fun assertAccepted(result: PolicyResult<*>) {
if (result is PolicyResult.Rejected) fail("expected Accepted, got Rejected: ${result.reason}")
}
private fun assertRejected(
result: PolicyResult<*>,
reasonContains: String? = null,
) {
when (result) {
is PolicyResult.Accepted -> {
fail("expected Rejected, got Accepted")
}
is PolicyResult.Rejected -> {
reasonContains?.let {
assertTrue(
result.reason.contains(it),
"expected reason to contain '$it', got '${result.reason}'",
)
}
}
}
}
// -- KindAllowDenyPolicy -------------------------------------------------
@Test
fun kindPolicyEmptyListsAreNoOp() {
val p = KindAllowDenyPolicy()
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 99))))
}
@Test
fun kindAllowListExcludesEverythingElse() {
val p = KindAllowDenyPolicy(allow = setOf(1, 7))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 7))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 not allowed")
}
@Test
fun kindDenyListBlocksLastWordOverAllowList() {
// When both lists are set, allow is a permissive ceiling and
// deny still removes specific kinds inside it.
val p = KindAllowDenyPolicy(allow = setOf(1, 4, 7), deny = setOf(4))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 denied")
assertRejected(p.accept(EventCmd(event(kind = 999))), reasonContains = "not allowed")
}
// -- PubkeyAllowDenyPolicy ----------------------------------------------
@Test
fun pubkeyAllowList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(allow = setOf(alice))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "not on allow")
}
@Test
fun pubkeyDenyList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(deny = setOf(mallory))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "denied")
}
@Test
fun pubkeyMatchIsCaseInsensitive() {
val pk = "ABCDEF".padEnd(64, '0')
val p = PubkeyAllowDenyPolicy(deny = setOf(pk.lowercase()))
// Event arrives with the upper-case form; policy must match.
assertRejected(p.accept(EventCmd(event(pubKey = pk))))
}
// -- RejectFutureEventsPolicy -------------------------------------------
@Test
fun futureEventsBeyondSkewAreRejected() {
val now = 1_000_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now + 60))))
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertAccepted(p.accept(EventCmd(event(createdAt = now - 9999)))) // past is fine
assertRejected(p.accept(EventCmd(event(createdAt = now + 61))), reasonContains = "future")
}
@Test
fun futureEventsZeroSkewMeansOnlyPastOrPresent() {
val now = 1_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 0, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertRejected(p.accept(EventCmd(event(createdAt = now + 1))))
}
// -- MaxEventBytesPolicy ------------------------------------------------
@Test
fun maxBytesAllowsSmallEvents() {
val small = event(content = "a")
val limit = OptimizedJsonMapper.toJson(small).length + 100
val p = MaxEventBytesPolicy(maxBytes = limit)
assertAccepted(p.accept(EventCmd(small)))
}
@Test
fun maxBytesRejectsOversizedEvents() {
val big = event(content = "x".repeat(2_000))
val p = MaxEventBytesPolicy(maxBytes = 500)
assertRejected(p.accept(EventCmd(big)), reasonContains = "exceeds limit")
}
// -- RateLimitPolicy ----------------------------------------------------
/** Helper that makes a clock we can advance in nanoseconds. */
private class FakeClock {
var nanos = 0L
fun read(): Long = nanos
fun advanceMillis(ms: Long) {
nanos += ms * 1_000_000L
}
}
@Test
fun rateLimitMessagesPerSecond() {
val clock = FakeClock()
val p = RateLimitPolicy(messagesPerSec = 3, nowNanos = clock::read)
// First three pass within the same instant.
repeat(3) { assertAccepted(p.accept(EventCmd(event()))) }
// Fourth is rate-limited.
assertRejected(p.accept(EventCmd(event())), reasonContains = "messages per second")
// After enough wall-time the bucket refills.
clock.advanceMillis(400) // 1s/3 = 333ms per token; 400ms gives at least 1 token
assertAccepted(p.accept(EventCmd(event())))
}
@Test
fun rateLimitSubscriptionsPerMinute() {
val clock = FakeClock()
val p = RateLimitPolicy(subscriptionsPerMin = 2, nowNanos = clock::read)
val req = ReqCmd("sub-1", listOf(Filter()))
assertAccepted(p.accept(req))
assertAccepted(p.accept(req))
assertRejected(p.accept(req), reasonContains = "subscriptions per minute")
// Refill after 30s for 2/min -> 1 token.
clock.advanceMillis(31_000)
assertAccepted(p.accept(req))
}
@Test
fun rateLimitCountAlsoCountsAsSubscription() {
val clock = FakeClock()
val p = RateLimitPolicy(subscriptionsPerMin = 1, nowNanos = clock::read)
val cnt = CountCmd("q1", listOf(Filter()))
assertAccepted(p.accept(cnt))
assertRejected(p.accept(cnt), reasonContains = "subscriptions per minute")
}
@Test
fun rateLimitDisabledWhenBothLimitsAreNull() {
val p = RateLimitPolicy()
repeat(1000) { assertAccepted(p.accept(EventCmd(event()))) }
repeat(1000) { assertAccepted(p.accept(ReqCmd("s", listOf(Filter())))) }
}
// -- Stack composition --------------------------------------------------
/**
* Verifies that policies compose via `IRelayPolicy.plus` so an
* EVENT must clear every policy in the stack to be accepted.
*/
@Test
fun stackedPoliciesAllMustAccept() {
val now = 1_000L
val stack =
(KindAllowDenyPolicy(allow = setOf(1)) as com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy) +
RejectFutureEventsPolicy(maxFutureSeconds = 10, now = { now })
// Allowed kind, in window — accepted.
assertAccepted(stack.accept(EventCmd(event(kind = 1, createdAt = now))))
// Allowed kind, future timestamp — rejected by RejectFuture.
assertRejected(
stack.accept(EventCmd(event(kind = 1, createdAt = now + 1000))),
reasonContains = "future",
)
// Disallowed kind — rejected by KindPolicy regardless of timestamp.
assertRejected(
stack.accept(EventCmd(event(kind = 99, createdAt = now))),
reasonContains = "not allowed",
)
}
@Test
fun rateLimitConstructorRejectsInvalidValues() {
var threw = false
try {
RateLimitPolicy(messagesPerSec = 0)
} catch (_: IllegalArgumentException) {
threw = true
}
assertEquals(true, threw)
}
}