send_join responses are bound to the joined room; own-domain events are always signature-checked¶
- Status: accepted
- Date: 2026-09-29
Technical Story: finding FED-1 (critical) in
docs/security-audit-report-2026-09-29.md.
Context and Problem Statement¶
When a local user joins a remote room, join_room stores the resident
server's send_join response: the state array (the room state before the
join) and the auth_chain. Three things about that ingestion let a local user
who controls a remote server rewrite the state of any room hosted here:
- Nothing checked that an event in
stateorauth_chainbelonged to the room being joined.ingest_send_join_statestored each event under its ownroom_idand wrote it to that room'scurrent_state. filter_verified_send_join_eventskept every event whose sender was on our own server without any signature check ("self-signed, we hold that key"). An unsignedm.room.power_levelsnaming a local sender passed.- The auth-chain loop wrote each auth-chain event to
current_statetoo, although those events are historical ancestors (outliers), not the room's state.
The spec is plain on (2): "Checks performed on receipt of a PDU … 2. Passes
signature checks, otherwise it is dropped." It has no exception for events
that name the receiving server. On (1), "Joining Rooms" defines state and
auth_chain as the state and auth chain of the room being joined. How the
server enforces that, and how it checks an own-domain event, are decisions.
Considered Options¶
- Keep trusting own-domain events; only add the room binding.
- Verify own-domain events against this server's own signing keys, read from
the persisted key rows it generated (current and retired); bind every entry
to the joined room before verification and again at the writer; store
auth-chain events with no
current_staterow. - Verify own-domain events through
remote_key_resolverlike any other domain (fetch our own/_matrix/key/v2/server). - Drop every own-domain event in a
send_joinresponse outright.
Decision Outcome¶
Chosen option: the second.
- Room binding, twice.
filter_send_join_events_for_roomdrops everystateandauth_chainentry whoseroom_idis not the room being joined, before any key resolution, on both the synchronous and the background (partial-state) path. Under v12, wherem.room.createhas noroom_id, the create event belongs only when"!"+ its reference-hash event ID equals the joined room ID; a v12 create event that carries aroom_idis dropped (v12 auth rule 1.2 rejects it).ingest_send_join_statetakes the joined room ID and repeats the check, so the writer itself can never touch another room. Theeventfield of thesend_joinresponse is not consumed at all; the join event we store is the one we signed from the validatedmake_jointemplate, whoseroom_idmust equal the requested room. - Own-domain events are verified against our own keys. The key is looked
up by the signature's key ID among the
server_signing_keysrows for our server name that hold a secret — rows this server generated, the same set published asverify_keysandold_verify_keys. A row without a secret is a copy fetched from somewhere and is not trusted as ours. The ADR-0075 validity rule applies as for any key. A key ID we do not hold, or a signature that does not verify, drops the event. - Our own key rows cannot be replaced from outside. Because own-domain
verification trusts those rows, the remote-key resolver never fetches or
caches keys for our own server name; it answers that name only from the
same secret-holding rows (
federation::find_own_server_signing_key). Andstore_server_signing_keyrefuses a write without a secret that would change the public key of a row holding one; retiring a key (same public key, newvalid_until_ts) still works. The guard is in C++, before the upsert, so it does not depend on how a backend storessecret_key. - Auth-chain events never become current state. They are stored as
outliers with no
current_staterow, andrepair_missing_state_entriesonly promotes events whose status isaccepted, so a restart cannot turn an outlier (or a rejected event) into current state either.
Rejected alternatives:
- Keep the own-domain shortcut. It looks like a free optimisation — "we hold that key" — but the shortcut never used the key; it accepted anything that named our domain, which is exactly what FED-1 exploited. A resident server has no reason to send us events we authored that we cannot verify.
- Resolve our own keys over the network. Discovery of our own server name
goes through DNS and
.well-known, which an attacker may influence, and the answer would be cached into the same rows as our real keys. We already hold the authoritative public keys locally. - Drop own-domain events outright. A re-join legitimately receives our own
users' earlier membership events back in
state; dropping them would leave the joined room's state incomplete.
Positive Consequences¶
- A
send_joinresponse can only ever write the joined room's state, and only with events whose signatures verify. - Legitimate re-joins still accept our own users' events.
Negative Consequences¶
- An own-domain event signed with a key this server has since deleted from
server_signing_keysis dropped. Keys are retired, not deleted, so this only affects a server whose key rows were removed by hand. - Auth-chain-only state events have no
state_transitionsrow, sounsigned.replaces_statecannot name one of them as a predecessor.