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:
@@ -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."
|
||||||
|
|||||||
+52
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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
|
||||||
|
}
|
||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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.
|
||||||
|
|||||||
+140
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user