mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
refactor(quartz): enforce session-level limits through policy hooks
Previously max_message_length and max_subscriptions were hard-coded in RelaySession behind a parallel `limits` param, while the per-command limits went through LimitsPolicy — two mechanisms, and a custom policy couldn't influence the session-level ones. Unify them: add two default-noop hooks to IRelayPolicy — acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) — chained through PolicyStack so they compose across multiple policies. LimitsPolicy now implements all limit checks; RelaySession just invokes the hooks and no longer takes a `limits` param. Servers compose LimitsPolicy whenever `limits` is set and keep `limits` only to advertise via NIP-11. Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED rate-limited:); the enforcement now lives in the policy layer. Adds direct hook unit tests; existing end-to-end limit tests still pass. https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
This commit is contained in:
+12
-9
@@ -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`.
|
||||
|
||||
|
||||
+26
@@ -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.
|
||||
*
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+8
-19
@@ -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(
|
||||
|
||||
+11
-19
@@ -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<String, Job>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+20
-2
@@ -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<EventCmd> {
|
||||
val event = cmd.event
|
||||
limits.maxContentLength?.let {
|
||||
|
||||
+17
@@ -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 <T : Command> runPolicies(
|
||||
initialCmd: T,
|
||||
operation: (IRelayPolicy, T) -> PolicyResult<T>,
|
||||
|
||||
+23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user