Every PDU ingestion path verifies a restricted join's authorising-server signature¶
- Status: accepted
- Date: 2026-10-21
Context and Problem Statement¶
The room version auth rules say that an m.room.member event whose content carries
join_authorised_via_users_server must be rejected unless it is validly signed by the homeserver
of that user (rooms/v8.md and v10.md rule 4.2.1; v12.md rule 5.2.1). Checking a signature needs
that server's key, and events::authorize_event_against_auth_events is a pure function with no
key resolver, so it can only see that a signature entry is present.
The 2026-09-29 fix (EVT-1) added the cryptographic check to the resolver overload of
federation::authorize_federation_pdu, which only the /send and send_join paths call.
Security audit run-1 (2026-10-20) found two more paths that put PDUs into the event graph and
called only the sender-signature check: main's re-verification of PDUs relayed by the federation
worker (worker_pool.cpp, ADR-0071) and backfill / missing-history ingestion
(verify_and_store_backfilled_event in local_http_router.cpp). In a restricted room this was
latent only because those paths' auth maps (build_auth_event_map_from_entries) never carry the
authorising user's membership, so the restricted branch refuses every non-invited join first.
That is a separate bug, and fixing it without this check would let a compromised worker on the
first path, or any remote server answering a history fetch on the second, get a forged
restricted join stored. In rooms with other join rules, a forged authorising signature was
already accepted, against the rule.
Where should the check live so that a new ingestion path cannot miss it?
Considered Options¶
- Move the check into
events::authorize_event_against_auth_events, passing it a key resolver. - One shared federation helper,
federation::verify_join_authorising_server_signature, called by every ingestion path right after the sender-signature check. - Add the check separately at each call site.
Decision Outcome¶
Chosen option: "one shared federation helper", because the auth rules stay a pure function (they
also run inside state resolution and on stored events, where no network fetch may happen), while
the key lookup stays with each caller. Each caller passes its own admitted lookup
(resolve_verification_key, or the /send path's adapter over its resolver), so each keeps its
FED-10 admission and its overload handling. A local admission denial comes back as a retryable
503, never as a verdict on the signature: the relay answers main_overloaded, and backfill sets
its budget's key_resolution_locally_denied.
The rule for future code: a new path that puts a remote PDU into the event graph calls
verify_join_authorising_server_signature after the sender-signature check, and refuses the
event unless it accepts. The /send path does this through the resolver overload of
authorize_federation_pdu; relay, backfill and the send_join state this server stores when
it joins a remote room (filter_verified_send_join_events) call it directly. The last of these
was added in review: it is the one path where this server is the joiner, and its state and auth
chain enter the event graph like any other received event.
Any one valid signature from the authorising server satisfies the rule, and the signatures
object is not itself signed, so a relaying server can add key IDs ahead of the genuine one. The
helper therefore tries each listed key in order until one verifies, but no more than
max_authorising_signature_keys (4): every candidate may cost a key fetch, and an unbounded list
would let one event amplify key fetches. A local overload on any candidate, with none verified,
is a retryable 503. A caller with a wall-clock budget passes its deadline, and no candidate
lookup starts after it (a 504, again no verdict); backfill does, so several uncached candidates
cannot hold recovery past its deadline by more than the one lookup already in progress.
The rule is triggered by the key being present, so a value that is not a user ID (a number, an object, a string without a server name) is refused: it denotes no homeserver that could have signed the event. It is never treated as an absent key.
The helper checks only m.room.member events, as the rule says. The earlier /send-only code
also checked other event types that carried the field; that was stricter than the spec and is
dropped.
Positive Consequences¶
- One implementation of the rule, with one set of tests, shared by every ingestion path.
- The structural check in the auth rules remains as a second line: an event with no signature entry at all is rejected there too.
Negative Consequences¶
- The auth rules alone are not enough for a remote event. Anything that admits a remote event
without going through one of the three paths would reopen the gap. This ADR and
src/federation/AGENTS.mdrule 2 say so.