From a76a1911ea16605182aea6e0998a8fe05bbc71aa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 2 Aug 2026 10:14:07 -0400 Subject: [PATCH] Stop COUNT from inheriting the default page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay holding 12,289,614 profiles answers ["COUNT","c1",{"kinds":[0]}] -> {"count":500} LimitsPolicy ran the same clampLimits over CountCmd as over ReqCmd, so an unbounded COUNT was given RelayLimits.defaultLimit and the store then counted at most that many. defaultLimit answers "how many events should a REQ return when the client names none". A COUNT returns no events, so the question has no meaning for it, and applying the answer anyway turns every unbounded COUNT into min(matches, defaultLimit). The failure is quiet, which is what let it survive: 500 is a plausible number, and the kinds under the default answered correctly. On the relay that surfaced it, kinds 1 and 10040 were right while 0, 10002 and 30382 were all exactly 500. maxLimit still applies — a client asking to count at most N is asking something a relay may bound. Only the invented default is dropped. Co-Authored-By: Claude Opus 5 (1M context) --- .../relay/server/policies/LimitsPolicy.kt | 28 ++++++++++++++- .../nip01Core/relay/server/RelayLimitsTest.kt | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) 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 e711734d5b..3aaa3e3f32 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 @@ -72,9 +72,13 @@ class LimitsPolicy( return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped)) } + /** + * A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given + * [RelayLimits.defaultLimit] — see [capLimits]. + */ override fun accept(cmd: CountCmd): PolicyResult { subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) } - val clamped = clampLimits(cmd.filters) + val clamped = capLimits(cmd.filters) return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped)) } @@ -106,6 +110,28 @@ class LimitsPolicy( return filters.map { it.copy(limit = targetLimit(it.limit)) } } + /** + * Cap what a COUNT asks for, without inventing a page size for it. + * + * `defaultLimit` answers "how many events should a REQ return when the + * client names no limit". A COUNT returns no events, so that question has + * no meaning for it — and applying the answer anyway turns every unbounded + * COUNT into `min(matches, defaultLimit)`. + * + * Silently: a relay holding 12,289,614 profiles replied `{"count":500}`, + * which is a plausible-looking number, so a client cannot tell it from the + * truth. The kinds that happened to fall under the default were correct, + * which is what made it survive. + * + * `maxLimit` still applies, because a client that explicitly asks to count + * at most N is asking a question this relay may bound. + */ + private fun capLimits(filters: List): List { + val max = limits.maxLimit ?: return filters + if (filters.none { it.limit != null && it.limit!! > max }) return filters + return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it } + } + private fun targetLimit(current: Int?): Int? = when { current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit 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 4fa12f80d7..55f6aa7139 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 @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class RelayLimitsTest { @@ -157,6 +158,39 @@ class RelayLimitsTest { ) } + @Test + fun countNeverGetsTheDefaultLimit() { + // A COUNT returns no events, so "how many should a REQ return by + // default" is not a question it asked. Applying the answer anyway turns + // every unbounded COUNT into min(matches, defaultLimit) — a relay + // holding 12,289,614 profiles replied {"count":500}, which is plausible + // enough that no client can tell it from the truth. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1))))) as PolicyResult.Accepted + + assertNull( + result.cmd.filters + .single() + .limit, + ) + } + + @Test + fun countStillHonoursAnExplicitMaxLimit() { + // Asking to count at most N is a question the relay may bound. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1), limit = 900)))) as PolicyResult.Accepted + + assertEquals( + 100, + result.cmd.filters + .single() + .limit, + ) + } + @Test fun leavesAcceptableRequestUnchanged() { val policy = LimitsPolicy(RelayLimits(maxLimit = 100))