Refuse E2EE key and filter uploads at per-user caps; store filters canonically¶
- Status: accepted
- Deciders: James Chapman
- Date: 2026-10-06
Technical Story: 2026-09-29 security audit finding CSAZ-10.
Context and Problem Statement¶
One-time keys, key-signature uploads and filters had no per-user limit, so one
account could grow the store, and the /keys/query signature scan run under the
runtime lock, without bound. Each needs a cap. What should happen to an upload
that would cross it, and how is an identical filter recognised without making
every filter upload re-parse every stored filter?
Decision Drivers¶
- A cap must bound memory and the work one request can cause.
- Normal clients must never reach it: matrix-rust-sdk keeps about 50 one-time keys per device, and clients re-create the same few filters.
- Nothing a client already relies on may silently disappear.
Considered Options¶
- Refuse an upload that would cross a cap, storing nothing from it.
- Evict the oldest entries to make room.
- For filters: compare a new filter with each stored one by parsing both.
- For filters: store new filters in canonical JSON and compare text.
Decision Outcome¶
Chosen options 1 and 4, decided by the project owner on 2026-10-06.
An upload that would take a user over server.client_api.max_one_time_keys_per_device
(1000), max_key_signatures_per_user (10000) or max_filters_per_user (1000) is
refused with 400 M_TOO_LARGE before anything from it is stored. Eviction is
rejected because what it drops is still in use: a filter ID a long-running client
syncs with, or a one-time key the client believes is published.
A filter whose canonical JSON equals one the user already stores returns the
existing filter_id, even at the cap. New filters are stored in canonical form so
that comparison is a byte match. Option 3 is rejected because one upload could
then force up to max_filters_per_user parses of bodies up to the request size.
Fallback keys are not a decision: the spec requires one per algorithm per device, and a new one replaces the old.
Positive Consequences¶
- Each per-user table is bounded, and a refused request leaves no partial state.
- Re-creating an existing filter neither grows the store nor costs a parse per row.
Negative Consequences¶
- The Matrix API has no way to delete a filter, so a user who does reach
max_filters_per_userdistinct filters stays there until an operator raises the cap. Deduplication makes that unlikely for real clients. GET /user/{userId}/filter/{filterId}returns the canonical, equivalent JSON, not the bytes sent. Filters stored before this change are compared as stored, so an old non-canonical filter is not deduplicated against its canonical twin.