diff --git a/quartz/RELAY.md b/quartz/RELAY.md index 7183694a08..c4fd2fce59 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -333,16 +333,19 @@ val limits = RelayLimits( val server = NostrServer(store, policyBuilder = { FullAuthPolicy(relay) }, limits = limits) ``` -Enforcement is split by where each limit can be checked: +All of it is enforced by a single `LimitsPolicy` (which the server prepends to +your policy when you pass `limits`), so limits compose through a `PolicyStack` +like any other policy — you can also split them across several policies: -- **Per-command** (a `LimitsPolicy` the server prepends to your policy): rejects - EVENTs over `maxContentLength` / `maxEventTags` / outside the `createdAt` - bounds; rejects REQ/COUNT with too many filters or an over-long sub id; and - **clamps** each filter's `limit` to `maxLimit` (substituting `defaultLimit` - when none is given). Rejections use the `invalid:` machine-readable prefix. -- **Per-connection** (`RelaySession`): oversized frames get a `NOTICE` - (`maxMessageLength`); a new subscription past `maxSubscriptions` is `CLOSED` - with `rate-limited:`. +- **Per-command** (`accept(...)`): rejects EVENTs over `maxContentLength` / + `maxEventTags` / outside the `createdAt` bounds; rejects REQ/COUNT with too + many filters or an over-long sub id; and **clamps** each filter's `limit` to + `maxLimit` (substituting `defaultLimit` when none is given). Rejections use + the `invalid:` machine-readable prefix. +- **Per-connection** (the `acceptMessage` / `acceptSubscription` policy hooks): + oversized frames get a `NOTICE` (`maxMessageLength`); a new subscription past + `maxSubscriptions` is `CLOSED` with `rate-limited:`. These hooks exist because + a policy otherwise can't see the raw frame or the live subscription count. - **Advertised only**: `minPowDifficulty` (enforce with a PoW policy), `authRequired` (use `FullAuthPolicy`), `paymentRequired`, `restrictedWrites`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt index f698577b6f..0e8bd170f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -102,6 +102,32 @@ interface IRelayPolicy { */ fun onAuthenticationFailed(pubKey: HexKey) {} + /** + * Inspects a raw inbound message before it is parsed. Return a reason + * string to reject it (the engine sends it as a `NOTICE`), or null to let + * it through. This is the only hook that sees the unparsed frame, so guards + * that must run before JSON parsing live here — e.g. a `max_message_length` + * cap (see [com.vitorpamplona.quartz.nip01Core.relay.server.policies.LimitsPolicy]). + * + * Called on every inbound message, so keep it cheap. Default: accept. + */ + fun acceptMessage(message: String): String? = null + + /** + * Decides whether a new subscription may open on this connection, given how + * many are already open ([openSubscriptions]). Called only for a genuinely + * new subscription id — a REQ that replaces an existing id does not grow the + * count. Return a reason string to reject it (the engine sends a `CLOSED`), + * or null to allow it. This is where a `max_subscriptions` cap lives, since + * a policy otherwise can't see the per-connection subscription count. + * + * Default: accept. + */ + fun acceptSubscription( + subId: String, + openSubscriptions: Int, + ): String? = null + /** * Filters a live event before it is forwarded to a subscriber. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index 621f9f7808..082432f5c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -102,7 +102,7 @@ class NostrServer( */ private fun buildPolicy(): IRelayPolicy { val base = policyBuilder() - return if (limits != null && limits.hasCommandLimits()) LimitsPolicy(limits) + base else base + return if (limits != null) LimitsPolicy(limits) + base else base } /** @@ -118,7 +118,6 @@ class NostrServer( store = subStore, scope = scope, onSend = send, - limits = limits, onClose = { closed -> // Idempotent: only account for the first teardown of a // given connection so a double close() can't underflow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimits.kt index 65c92af0d9..a743a40aba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimits.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimits.kt @@ -35,14 +35,14 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInform * Every field is optional; a null leaves that limit unset (not enforced, not * advertised). All fields map 1:1 to NIP-11's `limitation` object. * - * Enforcement responsibility: - * - Per-command ([LimitsPolicy]): [maxFilters], [maxLimit], [defaultLimit], - * [maxSubidLength], [maxEventTags], [maxContentLength], [createdAtLowerLimit], - * [createdAtUpperLimit]. - * - Per-connection ([RelaySession]): [maxMessageLength], [maxSubscriptions]. - * - Advertised only (enforced elsewhere or operationally): [minPowDifficulty] - * (NIP-13), [authRequired] (use a `FullAuthPolicy`), [paymentRequired], - * [restrictedWrites]. + * All of these are enforced by a single [LimitsPolicy] in the policy chain — the + * per-command ones via `accept(...)`, and the per-connection ones + * ([maxMessageLength], [maxSubscriptions]) via the `acceptMessage` / + * `acceptSubscription` policy hooks — so limits compose like any other policy. + * + * Advertised only (enforced elsewhere or operationally): [minPowDifficulty] + * (NIP-13), [authRequired] (use a `FullAuthPolicy`), [paymentRequired], + * [restrictedWrites]. */ class RelayLimits( /** Max size of an incoming message; oversized frames get a NOTICE. Measured in UTF-16 chars. */ @@ -74,17 +74,6 @@ class RelayLimits( /** Reject events with `created_at` after this epoch-second. */ val createdAtUpperLimit: Long? = null, ) { - /** Whether any of the per-command [LimitsPolicy] fields are set. */ - fun hasCommandLimits(): Boolean = - maxFilters != null || - maxLimit != null || - defaultLimit != null || - maxSubidLength != null || - maxEventTags != null || - maxContentLength != null || - createdAtLowerLimit != null || - createdAtUpperLimit != null - /** Renders these limits as a NIP-11 `limitation` object for the relay info document. */ fun toNip11Limitation(): RelayInformationLimitation = RelayInformationLimitation( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 262ee712db..424523af53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -68,13 +68,6 @@ class RelaySession( * open/close of the same connection. Defaults to a fresh monotonic id. */ val id: Long = nextConnectionId(), - /** - * Per-connection limits enforced here: the [RelayLimits.maxMessageLength] - * frame-size cap and the [RelayLimits.maxSubscriptions] cap. Per-command - * limits are enforced by [com.vitorpamplona.quartz.nip01Core.relay.server.policies.LimitsPolicy] - * in the policy chain, not here. Null disables session-level limits. - */ - private val limits: RelayLimits? = null, ) : AutoCloseable { private val subscriptions = LargeCache() @@ -117,11 +110,9 @@ class RelaySession( * Parses the message as a NIP-01 command and dispatches it. */ suspend fun receive(command: String) { - limits?.maxMessageLength?.let { max -> - if (command.length > max) { - send(NoticeMessage("invalid: message too large (max $max)")) - return - } + policy.acceptMessage(command)?.let { reason -> + send(NoticeMessage(reason)) + return } val cmd = @@ -241,13 +232,14 @@ class RelaySession( // -- NIP-01: REQ ---------------------------------------------------------- private fun handleReq(cmd: ReqCmd) { - // Enforce the per-connection subscription cap for *new* sub ids. A - // re-REQ on an existing id replaces it 1-for-1 (handled below) and so - // doesn't grow the count. Checked before the cancel so the existing - // subscription isn't dropped only to then reject its replacement. - limits?.maxSubscriptions?.let { max -> - if (!subscriptions.containsKey(cmd.subId) && subscriptions.size() >= max) { - send(ClosedMessage.of(cmd.subId, MachineReadablePrefix.RATE_LIMITED, "too many concurrent subscriptions (max $max)")) + // Ask the policy whether a *new* subscription may open (e.g. a + // max_subscriptions cap). A re-REQ on an existing id replaces it + // 1-for-1 and doesn't grow the count, so it's exempt. Checked before + // the cancel so the existing subscription isn't dropped only to then + // reject its replacement. + if (!subscriptions.containsKey(cmd.subId)) { + policy.acceptSubscription(cmd.subId, subscriptions.size())?.let { reason -> + send(ClosedMessage(cmd.subId, reason)) return } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/ReqResponderServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/ReqResponderServer.kt index 4ed0c4b2ed..fb526ee2af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/ReqResponderServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/ReqResponderServer.kt @@ -97,7 +97,7 @@ class ReqResponderServer( private fun buildPolicy(): IRelayPolicy { val base = policyBuilder() - return if (limits != null && limits.hasCommandLimits()) LimitsPolicy(limits) + base else base + return if (limits != null) LimitsPolicy(limits) + base else base } /** @@ -113,7 +113,6 @@ class ReqResponderServer( store = backend, scope = scope, onSend = send, - limits = limits, onClose = { closed -> // Idempotent teardown accounting (see NostrServer.connect). if (connections.remove(closed.id) != null) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt index f1071c5851..e8c085af90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt @@ -39,12 +39,30 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.RelayLimits * Compose it ahead of your own policy so requests are clamped/rejected before * the application logic runs — `LimitsPolicy(limits) + myPolicy`. The server * classes do this automatically when you pass them `limits`. The session-level - * caps ([RelayLimits.maxMessageLength], [RelayLimits.maxSubscriptions]) are not - * enforceable from a policy and live in `RelaySession`. + * caps ([RelayLimits.maxMessageLength], [RelayLimits.maxSubscriptions]) are + * enforced through the [acceptMessage] / [acceptSubscription] policy hooks, so + * everything limit-related composes uniformly across a [PolicyStack]. */ class LimitsPolicy( private val limits: RelayLimits, ) : PassThroughPolicy() { + override fun acceptMessage(message: String): String? { + val max = limits.maxMessageLength ?: return null + return if (message.length > max) MachineReadablePrefix.INVALID.format("message too large (max $max)") else null + } + + override fun acceptSubscription( + subId: String, + openSubscriptions: Int, + ): String? { + val max = limits.maxSubscriptions ?: return null + return if (openSubscriptions >= max) { + MachineReadablePrefix.RATE_LIMITED.format("too many concurrent subscriptions (max $max)") + } else { + null + } + } + override fun accept(cmd: EventCmd): PolicyResult { val event = cmd.event limits.maxContentLength?.let { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt index 4e3d76ffd2..b9628aa26d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt @@ -60,6 +60,23 @@ class PolicyStack( policies.forEach { it.onAuthenticationFailed(pubKey) } } + override fun acceptMessage(message: String): String? { + for (policy in policies) { + policy.acceptMessage(message)?.let { return it } + } + return null + } + + override fun acceptSubscription( + subId: String, + openSubscriptions: Int, + ): String? { + for (policy in policies) { + policy.acceptSubscription(subId, openSubscriptions)?.let { return it } + } + return null + } + private inline fun runPolicies( initialCmd: T, operation: (IRelayPolicy, T) -> PolicyResult, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt index adcacad7e8..535091631a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt @@ -91,6 +91,29 @@ class RelayLimitsTest { assertTrue(policy.accept(EventCmd(event(createdAt = 150L))) is PolicyResult.Accepted) } + // -- LimitsPolicy: session-level hooks ------------------------------------- + + @Test + fun acceptMessageRejectsOversizedFrame() { + val policy = LimitsPolicy(RelayLimits(maxMessageLength = 10)) + assertEquals(null, policy.acceptMessage("short")) + val reason = policy.acceptMessage("this is way too long") + assertTrue(reason != null && reason.startsWith("invalid:")) + } + + @Test + fun acceptMessageIsNoopWhenUnset() { + assertEquals(null, LimitsPolicy(RelayLimits()).acceptMessage("anything at all, no cap")) + } + + @Test + fun acceptSubscriptionRejectsAtCap() { + val policy = LimitsPolicy(RelayLimits(maxSubscriptions = 3)) + assertEquals(null, policy.acceptSubscription("s", openSubscriptions = 2)) + val reason = policy.acceptSubscription("s", openSubscriptions = 3) + assertTrue(reason != null && reason.startsWith("rate-limited:")) + } + // -- LimitsPolicy: REQ / COUNT --------------------------------------------- @Test