Skip to main content

rate limits

The #268 audit asked a question this repository had never answered in one place: [ratelimit] exists and every test disables it, so what is actually limited? This is the inventory. It is deliberately blunt about the unbounded column, because a limit nobody has written down is a limit nobody can tell is missing.

Two different things are bounded here, and they fail differently:

  • Rates β€” how often a caller may do something. Enforced by crates/spindle-server/src/ratelimit.rs, a fixed-window counter keyed by account and by source address, in memory (single-node; a multi-node deployment is #24's problem). Refused with 429 M_LIMIT_EXCEEDED and a retry_after_ms.
  • Caps β€” how much a caller may make this server hold or process at once. Enforced at the write, refused with 400 M_LIMIT_EXCEEDED (or the spec's own code where it has one), and never reset by waiting.

Rates​

There are six. Three sit on the unauthenticated edge. The other three are MatrixRTC's, and they are the only authenticated rates this server enforces: the one thing an account can do that makes every phone in a room sound (#39), and the two credential mints a call needs (#38).

EndpointKeyLimitCounted when
POST /login (password)account5 per 60 sa failed attempt; a success forgets both keys' history
POST /login (password)source address30 per 60 sa failed attempt
POST /registersource address5 per 300 sa request that carries auth, i.e. after the mandatory first 401
PUT /send/m.rtc.notification (and the MSC4075 unstable name)account[ratelimit] rings_per_minute, 10every attempt, delayed or not, whether or not the room then refuses it
POST /user/{userId}/openid/request_tokenuser20 per 60 severy request, before the token is minted
POST /_spindle/rtc/livekit/sfu/get (built-in LiveKit service)user20 per 60 severy request that carried a live OpenID token, before the membership check

The two mints are limited because they are not reads: an OpenID token is a durable row written on the caller's say-so, and both tokens exist to make a third party β€” a JWT service, an SFU β€” do work. Minting is an HMAC here and a room on the SFU there, which is the asymmetry #38 names. A client mints one of each per call it joins, so twenty a minute is a busy user several times over and a loop within seconds.

Both login keys are checked before the password is, so a caller over the limit does not get an Argon2 verification out of each attempt. Both are needed: per-account alone misses credential stuffing, per-source alone locks out everyone behind one NAT. The reasoning is in the module header.

Nothing else an authenticated account does is rate limited. Event sends, room creation, invites, joins, media uploads, device registration, sync β€” none of it. The ring is the exception because it is not an ordinary send: it is routed by m.mentions to every member it names at high priority, past do-not-disturb, and a script making a room ring continuously is a nuisance no other event can be (#39). That the rest is unlimited is a choice this document records rather than defends: the caps below bound what a single account can make the server hold, and the benchmark rig depends on being able to issue requests as fast as the server takes them. A per-account send rate is the obvious next limit if abuse ever needs one, and it belongs in [ratelimit] beside enabled.

Caps​

WhatCapConfigured byRefusal
Media upload body50 MiB (media::MAX_UPLOAD)fixed; advertised by /media/config413 M_TOO_LARGE
Filters held per user1,000[limits] filters_per_user400 M_LIMIT_EXCEEDED
Account-data types per user (global + per-room)20,000[limits] account_data_per_user400 M_LIMIT_EXCEEDED; rewriting an existing type is free
One-time keys held per device1,000[limits] one_time_keys_per_device400 M_LIMIT_EXCEEDED; counts held plus the batch offered
Pushers registered per user100[limits] pushers_per_user400 M_LIMIT_EXCEEDED; replacing a registration is free
Pending delayed events per sender per room100[delayed_events] max_per_room400 M_LIMIT_EXCEEDED
Longest delay24 h[delayed_events] max_delay_ms400, naming the limit
PDUs per federation transaction50fixed (the spec's own number)400 M_BAD_JSON
Federation transaction id length255 bytesfixed400 M_BAD_JSON; the id is a replay key, and an unbounded key is a storage sink
Peer key-document validity7 days regardless of what the peer claimsfederation::MAX_KEY_VALIDITYrefetch

Every configurable cap refuses zero at startup. A zero would reject every write, and a config that quietly disables a feature is worse than one that will not load.

Not bounded​

Each of these grows with what an ordinary registered account chooses to do, and nothing stops it. Listed with the shape a cap would take, so the next one is a small change and not a design discussion.

GrowthDriven byShape of a cap
Rooms created per userPOST /createRoomper-user count, in [limits]
Aliases per roomPUT /directory/room/{alias} (members only since #291)per-room count
Media objects, and bytes, per userPOST /upload, POST /createper-user count and total bytes; the object-level cap exists, the account-level one does not
Devices per userPOST /login on a new device, POST /registerper-user count
Room tags per room per userPUT /tags/{tag}per-user-per-room count
Invites outstanding per senderPOST /inviteper-user count of pending invites
Events per room, and rooms joined per userPUT /send, POST /joina per-account send rate (above), not a cap: a room's history is the product
EDUs per federation transactionPUT /send/{txnId}a count beside the PDU cap; the spec allows 100
Peer key fetches on a cache missany signed request from an unknown key idper-origin refetch rate; filed as #288

The first four are the ones #268 named and #297 did not reach. None is an attack that needs anything beyond a registered account. None is an emergency either: every row above is storage growth at the rate one account can generate it, on a server whose registration is rate limited and can be closed.

How to read this against the code​

The inventory is hand-written, which is the failure mode this repository usually refuses. The mitigation is that every row in the two bounded tables names the constant or config field that enforces it, so a reader can grep for it, and the [limits] and [delayed_events] fields are held to spindle.example.toml by scripts/config-drift.py. A cap that lands without a row here is not documented; a row here without a cap behind it is a lie. Keep both columns honest.