Threat Model¶
Initial attacker categories¶
- Malicious local users
- Malicious federated homeservers
- Remote resource exhaustion attackers
- Database exfiltration attackers
- Media upload attackers
- Malicious reverse proxies
- Supply-chain attackers
- Compromised administrators
- Malicious or compromised local process (e.g. a compromised federation worker)
High-risk surfaces¶
- Federation transaction parsing
- Federation room-scoped reads (
state,state_ids,event,backfill,get_missing_events) - Per-room server ACL enforcement (
m.room.server_acl) - Canonical JSON
- Event authorization
- State resolution
- Device and key APIs
- E2EE /keys/upload signature validation (verifies one-time and fallback key signatures against the device's own identity key, rejecting unverifiable keys with 400 M_INVALID_SIGNATURE)
- Token handling
- Server signing-key persistence
- Media handling
- Image decoding (thumbnail generation; isolated in a sandboxed worker)
- Outbound requests (SSRF via federation discovery and remote media fetch)
- URL previews (
preview_url: the server fetches a URL any authenticated user names; off by default) - Application Service API (outbound transaction delivery, deliberately not SSRF-filtered; namespace matching; masquerading)
- Identity Service client (outbound
store-invite/bind/unbind/requestToken) - Push gateway delivery (outbound, client-supplied URL, no operator allowlist)
- Email pushers (outbound SMTP to an operator-configured relay; a client-supplied recipient address)
- Federation worker IPC channel (local process boundary)
- MSC4186 sliding-sync connection state (client-chosen
conn_id) - Config parsing
- Database migrations
Trust boundaries¶
Each attacker class reaches the server through a specific boundary. The gate at that boundary must run, fail-closed, before any state is touched.
flowchart TB
localuser["Malicious local user"]
peer["Malicious federated server"]
proxy["Malicious reverse proxy"]
uploader["Media upload attacker"]
localproc["Malicious local process<br/>(on same host)"]
subgraph trusted["Trusted: validated server state"]
state[("Persistent store + runtime state")]
end
clientgate["Client edge gate<br/>token auth · rate limit · bounded parse"]
fedgate["Federation edge gate<br/>X-Matrix sig · PDU hash+sig · auth rules"]
headergate["Header/transport validation<br/>no test-only auth on prod listener"]
mediagate["Media decode boundary<br/>sandboxed worker · decode-bomb guard"]
ipcgate["IPC channel gate<br/>master-key-authenticated KX · AEAD frames · verified identity only · no secrets in transit"]
localuser --> clientgate --> state
peer --> fedgate --> state
proxy --> headergate --> clientgate
uploader --> mediagate --> state
localproc --> ipcgate --> state
| Attacker | Primary surface | Key mitigation |
|---|---|---|
| Malicious local user | Client-server API | Access-token auth, login-enumeration-resistant errors, rate limits, bounded parsers |
| Malicious federated server | Federation transactions | X-Matrix verification, per-PDU content-hash + sender-domain Ed25519 checks, auth rules before persist, EDU origin-ownership checks, and per-room m.room.server_acl enforcement on protected endpoints and inbound PDUs/EDUs |
| Remote exhaustion attacker | Listeners, queues, parsers | Bounded queues, rate limiting, resource limits, circuit breakers, connection dispatcher: no worker waits on an idle or unreadable connection, per-client worker share, minimum body rate, per-connection request and lifetime caps (ADR-0077) |
| Media upload attacker | Image decoding | Out-of-process seccomp/rlimit-sandboxed worker, pixel-count decode-bomb guard, MIME sniffing, quarantine |
| Malicious reverse proxy | Header/transport trust | Production listener rejects test-only credential encodings; response header validation; public listeners require TLS and cannot declare a local reverse proxy, while loopback cleartext requires an explicit reverse_proxy=true declaration |
| Malicious local process | IPC channel sniffing | Master-key-authenticated crypto_kx handshake (#318) + AEAD encryption; no filesystem socket path; signing key never loaded in worker and never forwarded over IPC (#317), and the worker holds no signing capability at all — no sign_request frame, a refusing provider (ADR-0078); main verifies inbound X-Matrix signatures and forwards only the verified peer identity — the raw peer access_token/Authorization never crosses IPC (#323) |
| DB exfiltration attacker | Persistence | Prepared statements only, runtime/migration role separation, audit redaction; at-rest encryption for the server signing secret when a master key is configured; Argon2id hashing for registration tokens |
| Supply-chain attacker | Dependencies, release | Vendored/pinned subprojects, secret scanning, SBOM; signing/provenance tracked in production milestone |
| Compromised administrator | Admin surface | Audited admin actions; richer admin authZ tracked as a gap |
Mitigations applied¶
-
MED-4, local thumbnail revalidation in progress: moderation could complete while an unlocked decoder was running, yet its finished PNG was returned from an earlier admission. Local thumbnail requests now retain owned media/storage identity and recheck the source record after decoding, before publication or served accounting. Quarantined/removed records fail closed even when another available record shares the same blob; database-read identity is not borrowed from a mutable repository record. The real SQLite/socket/worker-gate regression reproduces quarantine and removal leaks while concurrent
/versionsand admin controls pass. Normal GREEN, independent review and fresh combined verification are pending. Remote-source identity, output caching, per-user decode limits and strict trust-policy revocation are outside this slice; MED-4 remains partial. See proposed ADR-0145. -
MED-7, bounded admission repair: an authenticated remote homeserver could retrieve otherwise available local media despite a persisted media-policy denial. The federation provider now applies the existing media decision before repository resolution or blob reads, refusing with no bytes/content type and no successful multipart envelope. A genuine signed-request SQLite regression reproduced the bypass, checks durable admin denial plus an unaffected control, and verifies recovery after rule deletion. Configured-unavailable media-hook behavior is also covered directly at the provider, independently of the federation-wide hook. Independent review passes; the fresh normal full suite completes 56 OK, all return codes zero, with 1,026 frozen files unchanged. Live policy-server HTTP responses and policy-change-during-I/O revocation are outside the demonstrated boundary. This is not whole-audit or release acceptance. See ADR-0144.
Specific issues found and fixed, in the order they landed. Each entry names the threat it closes; the controls above are the standing defences these reinforce.
-
Unsolicited unknown-room event retention (FED-11, 0.12.16): valid signatures no longer authorize unlimited rejected-event storage for rooms with no current local join/invite/knock interest. Admission occurs before stream allocation, history fetches and persistence. Active outbound joins use bounded transient room queues, dropped on failure and drained only after verified state and local membership commit (ADR-0089). Solicited bootstrap/backfill paths remain explicit rather than privileges claimed by incoming event content. Focused regression and lifecycle tests pass; combined full-suite verification is pending.
-
Database credentials absent at startup (DB-3, 0.12.16): PostgreSQL is the production default, so silently replacing an unavailable connection with an in-memory store could acknowledge security-sensitive state that disappears on restart. PostgreSQL startup now fails when the URI file cannot be read or is empty. Tests select a separate programmatic in-memory backend explicitly; the config parser still accepts only PostgreSQL and SQLite.
-
Production federation-listener auth confusion: the production federation listener previously accepted a pipe-delimited fixture token format in addition to real
X-Matrixauthorization headers. A request path that is reachable from production traffic must not share test-only credential encodings. Fixed by accepting onlyAuthorization: X-Matrix ...onhandle_federation_http_request(). -
Login enumeration and unkeyed token-hash leakage: unknown users and bad passwords returned distinct external login errors, and bearer tokens were stored as unkeyed
token-hash:v2digests. Fixed by always performing a password-verification step, collapsing external failures toinvalid login, and issuing keyedtoken-hash:v3digests while retaining v2 lookup compatibility for existing persisted rows. -
Registration validation-session memory growth: repeated
/register/*/requestTokencalls could allocate unbounded validation-session entries. Fixed by pruning stale sessions and enforcing per-remote/global caps before allocating a new session. -
Inbound EDU spoofing and parser ambiguity: receipt, presence, and device-list EDUs were interpreted with ad hoc string scanning, allowing mismatched origin/user ownership checks to be skipped and spec-shaped receipt
event_idsarrays to be misread. Fixed by parsing canonical JSON objects and rejectinguser_ids whose server name does not match the sending origin. -
Response-header injection through runtime metadata: response headers were appended without shared validation. Fixed by validating header names/values before storing or formatting them and by emitting
X-Content-Type-Options: nosniffon every response. -
Relayed PDU signature bypass (C1):
authorize_federation_pdupreviously skipped Ed25519 verification for PDUs whose sender domain differed from the transport origin (i.e., relayed PDUs). A malicious relay could persist events attributed to any user on any server. Fixed by resolving the sender domain's signing key viaremote_key_resolverbefore authorizing; fail-closed when the resolver is wired but cannot produce a key. -
Relayed sender-key resolution amplification (FED-10): an authenticated relay could trigger discovery/key resolution for many event sender domains without origin bootstrap's admission controls or failure cache. Core inbound network-capable sender lookups share the source budget, runtime in-flight cap and server/key negative cache, including parallel and authorization fallback paths. Cache-only verification reads remain available when outbound resources are exhausted. A fresh key is not proof that discovery is cached: unknown-origin full resolution is still admitted, while known-peer key rotation reuses established discovery state. Cached paths never release another request's reservation. Resolver/permit lifetimes are exception-safe. Scope and rejected alternatives are recorded in ADR-0132.
Local admission denial is kept separate from remote failure: core inbound sender/authorising-key overload returns retryable 503 without changing relay trust or accepted-transaction dedup. Genuine failures retain their penalties, including in mixed transactions. See ADR-0133. Main IPC PDU/membership/invite re-verification now shares the admitted key-only helper. Cache misses use main's common unknown-source quota, not an address asserted by the worker, and local denial defers before mutation. Real encrypted in-process IPC regressions exercise all three paths, quota/capacity denial, unchanged-relay retry, cache identity/validity and bounded negative caching. See ADR-0134. Independent source-only review approved the main boundary. Backfilled-event, snapshot and auth-chain verification now also uses the admitted helper and common unknown-source bucket; local denial stops subsequent recovery calls and preserves the PDU overload result instead of ordinary missing state. Its real TLS/SQLite regression was RED (13 failed checks) then GREEN; the extension passed fresh full-suite verification and independent source-only review. Additional sticky-denial/partial-progress tests and end-to-end worker socket-HTTP overload coverage remain before whole-finding closure. See ADR-0135.
-
Malformed-first-PDU worker bypass (FED-9): the EDU-only
/sendshortcut inferred no persistent events from a missing first-PDU room ID. With workers enabled, a malformed first PDU followed by real PDUs could therefore enter main's request handler. Classification now requires an explicitly empty PDU array to bypass; malformed first-PDU transactions retain the empty shard key, skip the proxy's room-sync fallback and dispatch through worker shard zero. Main's JSON-envelope parsing and other existing fallback paths are unchanged. See ADR-0131. -
Thumbnail worker descriptor leak + privilege-escalation surface: the parent forked the image decoder with
pipe()(descriptor leak) and did not close other inherited descriptors or setPR_SET_NO_NEW_PRIVSbeforeexecv(). A compromised worker could access unrelated parent sockets/files or escalate via a setuid helper. Fixed by creating pipes withO_CLOEXEC, sweeping all non-stdio descriptors in the child, and setting no-new-privs before exec. -
Missing event-auth before persist (C2): The production
pdu_sinkpersisted inbound PDUs without callingauthorize_event_against_auth_events. A federated peer could persist events that violate the room's power-level and membership rules. Fixed by running full event-authorization against the room's current resolved state before persistence. -
Server signing secret stored plaintext at rest: the Ed25519 server signing secret seed was persisted as a base64 plaintext value in the database, so a DB exfiltration attacker could forge federation signatures and impersonate the server. Fixed by encrypting the seed with
secret_boxunder a domain-separated XSalsa20-Poly1305 key derived fromsecurity.secrets.master_key_file. The transparent plaintext fallback that remained for deployments without a master key was itself a hole — an attacker who could exfiltrate the database of an unconfigured server got the signing key regardless — and was removed in 0.12.5: both first generation and rotation now fail closed, and the server refuses to start without a usable master key. Legacy plaintext rows are still read so existing deployments keep federating until they rotate. -
SSO
redirectUrlallowlist matched by unbounded prefix: the allowlist check was a barestarts_with()with no origin or path boundary, so an operator entry naming a bare origin (https://client.example.com) also matchedhttps://client.example.com.evil.test/callback. An attacker who registered such a domain received a freshly mintedm.login.token— the exact open-redirect-plus-token-exfiltration the allowlist exists to prevent. Fixed in 0.12.5: the character following the matched prefix must be a URL delimiter (/,?,#) or end-of-string. -
Federation media served with a hardcoded
Content-Disposition: inline: the federation multipart body told the receiving homeserver's clients to render every content type inline, including types the local download path refuses to serve inline, so the inline-safe allow-list did not apply to anything reached over federation. Fixed in 0.12.5: both paths sharemedia::content_type_is_inline_safe()and fail closed toattachment. -
Master key usable from swappable memory:
load_master_key_material()tolerated a failedsodium_mlock()and returned the root secret in ordinary pageable memory after a one-time warning, soRLIMIT_MEMLOCKexhaustion silently downgraded every key derived from it (access-token HMAC, signing-secret box, IPC auth) and exposed them to swap and core dumps. Fixed in 0.12.5: an unlockable master key is refused outright. The remedy is a deployment change — raiseRLIMIT_MEMLOCKfor the service, or grantCAP_IPC_LOCK. -
Registration token stored and compared as plaintext: the shared registration token was loaded from config and compared with
sodium_memcmp, leaving the token in long-term process memory and exposing a timing side-channel. Fixed by hashing the token with Argon2id (crypto_pwhash_str) and verifying withcrypto_pwhash_str_verify; only the hash is retained, and the plaintext token is zeroised after hashing. -
Untrusted image decoding in-process: generating thumbnails requires decoding attacker-supplied PNG/JPEG bytes, and the C image parsers (libpng/libjpeg-turbo) are a historic memory-corruption surface. Decoding now happens in a short-lived, sandboxed
merovingian-thumbnail-workerchild process that — before reading any input — clamps its address space, CPU time, output size, and descriptor count viasetrlimit, setsPR_SET_NO_NEW_PRIVS, disables core dumps, and installs the seccomp-bpf filter. The worker holds no secrets, sockets, or filesystem access beyond its stdio pipes, so a decoder exploit is contained. The parent enforces a wall-clock timeout, input/output size caps, and a pixel-count decode-bomb guard, and SIGKILLs a worker that overruns. Crop intermediates also obey the existing effective pixel budget (MED-8), checked in 64-bit arithmetic before narrowing or allocation; a thin source cannot amplify a small final target into an unbounded fill buffer. Expected refusal uses an empty size-error frame, while unexpected C++ exceptions use an internal-error frame prepared after hardening and before input. This is a per-buffer bound, not an aggregate memory/concurrency or general thumbnail-conformance claim. The ISO-5 boundary additionally prevents environment and diagnostic inheritance: both exec paths use only a fixed PATH, and fd 2 points to a pre-opened/dev/nullsink. Main checks the full PNG signature and initial IHDR dimensions against the worker's report, rejecting malformed/mismatched output with 502 and no media payload. It does not decode or validate image data in-process. Seemedia/thumbnailer.hppand media-repository.md. -
Variable-length secret comparison leaking length (#8): comparing a config secret with a fixed-size function such as
sodium_memcmpup to the shorter length branches on the secret's size before comparing bytes. Fixed by adding a domain-separatedcrypto_generichashpath that produces fixed-size digests for both inputs and compares those digests withsodium_memcmp, hiding length differences. -
Runtime hardening controls not applied in-process (#9): the startup hardening self-check documented
core dump policy,no_new_privs, andcapability boundingas alpha exceptions without enforcing them. On Linux the server now clampsRLIMIT_COREto zero, setsPR_SET_NO_NEW_PRIVS, and drops the capability bounding set before serving traffic; the self-check reports the resulting status instead of a placeholder. -
Signing secret material in ordinary process memory (#10): the Ed25519 server signing secret was kept in a plain
std::vectorwhile loaded for signing and token-key derivation, leaving it exposed to swap and core dumps and copying it into regular containers. Fixed by storing the secret incore::SecretBuffer, which uses libsodiummlockand zeroises the buffer on destruction or move. -
Seccomp filter architecture guard was x86_64-only (#11): the seccomp-bpf architecture check hard-coded
AUDIT_ARCH_X86_64, so an aarch64 build would either mismatch the filter or silently accept a wrong constant. Fixed by selectingAUDIT_ARCH_X86_64orAUDIT_ARCH_AARCH64at compile time and returningSECCOMP_RET_KILL_PROCESSon any unsupported architecture. -
RELRO/BIND_NOW not explicit in linker flags (#12): the build and packaging scripts relied on toolchain defaults for partial RELRO and lazy binding, leaving GOT/PLT writable at runtime.
-Wl,-z,relroand-Wl,-z,noware now passed explicitly inmeson.buildand every packaging script, and the startup ELF probe verifiesPT_GNU_RELROandDT_BIND_NOW. -
Relayed-PDU fail-open with no sender-domain key (#270): the prior (C1) mitigation only fail-closed when
remote_key_resolverwas wired but returned no key. On a receive-only/locked-down deployment where the resolver is never wired (becauselocal_http_router.cppgates wiring onoutbound && discovery),authorize_federation_pdufell through to accept the PDU with no cryptographic check, so a known peer could forge events attributed to another server. Fixed by returning403 "sender domain signing key unavailable"whenever the sender-domain key is missing or mismatched, and removing the test-only two-arg overload that passedstd::nulloptso no path can exercise fail-open. -
Account lock/suspend did not gate already-issued tokens (#271): suspending or locking a user had no effect on access tokens already issued, so a moderated user retained full API access until token expiry. Fixed per spec v1.19 by gating the request path rather than revoking sessions: locked accounts get
M_USER_LOCKED(soft_logout:true) on all endpoints except/logoutand/logout/all, and suspended accounts getM_USER_SUSPENDEDon actions outside the spec allowlist. New admin endpoints/_matrix/client/v1/admin/lock/{userId}and/_matrix/client/v1/admin/suspend/{userId}set the state with anti-enumeration ordering (admin auth before any target lookup), locality, and self/other-admin guards. No proactive token revocation, conforming to spec. -
Password change left other devices' tokens valid (#272):
POST /account/passwordignoredlogout_devices(spec defaulttrue), so a token stolen from another device stayed valid after the victim changed their password. Fixed by readinglogout_devices(defaulttrue) and revoking every other device's access/refresh tokens and in-memory sessions while preserving the caller's device; device records are retained. -
Power-levels sender self-elevation (#273): the elevation guard in
events/authorization.cppexempted the sender (if (user_entry.key != *sender)), letting a moderator raise their own power above their current level in a single event and seize admin. Fixed by removing the exemption so spec rule 9.9 applies to the sender's own entry. -
Power-levels removal/demotion of a superior user unchecked (#274): the users-map loop iterated only the incoming
content.users, so omitting a superior user was never checked (they silently fell tousers_default), and the demotion guard used>instead of spec's>=. Fixed by iterating the union of old and newuserskeys and rejecting any change or removal of a user at or above the sender's power (spec rule 9.8), excluding the sender's own entry from the demotion check. -
Registration-token validity endpoint compared plaintext (#266):
GET /_matrix/client/v1/register/m.login.registration_token/validitycompared the configured registration token as plaintext, bypassing the Argon2id hashed-token comparator already used by/registerand leaving token material on the request path. Fixed by loading the hashed token viaload_hashed_registration_tokenand verifying the candidate withregistration_token_matches(crypto_pwhash_str_verify); only the hash is consulted. -
Media SSRF filter diverged from the federation single source of truth (#267):
media::address_is_private_or_loopbackwas a weak string-prefix duplicate of the robustinet_pton-basedfederation::ip_address_is_private_or_loopback, so remote-media fetch blocking could drift from the federation path. Fixed by delegating the media helper to the federation helper, eliminating the duplicate SSRF filter and its divergent edge cases. -
Token-hash lookups compared with
==(#268): five fixed-length token-hash comparisons (access/refresh store lookups and the in-memory session match) used==, a timing side-channel on secret bytes. Fixed by routing every fixed-length hash match throughcrypto::constant_time_equal/auth::constant_time_equal(sodium_memcmp), per the crypto-boundary rule. -
172.string fallback over- and under-blocked private ranges (#269): the string-prefix fallback's172.clause (address[4] >= '1' && address[4] <= '3') over-blocked public172.1–172.3and under-blocked the rest of172.16/12. Fixed by removing the clause; the172.16/12range is handled correctly by theinet_ptonnumeric path, and the remaining hostname prefixes stay for fail-safe handling of non-IP inputs. -
Access/refresh tokens never expired server-side (#275): tokens remained valid indefinitely despite the advertised 1-hour TTL, so a leaked token was usable forever and the advertised lifetime was unenforced. Fixed by adding an
expires_atfield toPersistentAccessToken,PersistentRefreshToken, andLocalSession, set at issuance from configurablesecurity.access_token_lifetime_ms(default 1h) andsecurity.refresh_token_lifetime_ms(default 30d);find_sessionand the refresh-token lookup reject expired tokens (audit reasontoken expired), forcing re-login/refresh. The advertisedexpires_in_msnow reads from the configured access-token lifetime so advertised == enforced. Regressed across restarts until 0.12.13:hydrate_local_databaserebuilt each session without itsexpires_at, andfind_sessionreads only the session, so after any restart every access token was valid forever again. Hydration now copies every field (test[session_restart]). -
Stolen refresh token used alongside the legitimate client (0.12.13, audit item 6, ADR-0074): rotation revoked the presented refresh token at once — which also broke the spec's guarantee that a client whose response was lost can retry — and nothing noticed an invalidated token being presented again. Now the old token stays valid until the new pair is first used, and presenting a refresh token that has been invalidated revokes the device's whole session (the spec's SHOULD), so whichever party presents a retired token ends the session for both and forces re-authentication.
-
SecretBufferwipe was elidable and moves left residue (#276): the destructor usedstd::ranges::fill(m_buffer, 0U), a dead store the compiler can elide, and default moves did not wipe, so signing-key residue was not reliably cleared and could survive in moved-from objects. Fixed bysodium_mlock-ing on construction andsodium_munlock-ing (which zeroises and unpins, an optimisation barrier) on destruction, with custom move-ctor / move-assign that transfer the mlock to the destination and wipe the source. -
Federation work starving client threads (out-of-process worker, v0.10.1): joining a large room via federation saturated the main thread pool with PDU verification, state resolution, and membership work, making all connected clients unresponsive. The attack surface is the IPC channel between the main process and the new
merovingian-fed-workerchild. Mitigations: (a) the channel uses a master-key-authenticatedcrypto_kxkey exchange (#318): both processes derive the same IPC auth key from the operator master-key file and MAC each other's ephemeral public keys before deriving session keys, so a local process that reaches the inherited fd without the master key cannot complete the handshake; frames are thencrypto_secretstream_xchacha20poly1305AEAD-encrypted so a process that can read the socket pair sees only ciphertext; (b) the main process verifies the inbound X-Matrix signature itself and forwards only the verified peer identity (origin/key_id/sig_verified) — the raw peeraccess_tokenandAuthorization/X-Matrixheaders are stripped and never cross IPC (#323), so a compromised worker cannot harvest or replay peer homeserver credentials; (c) the channel uses anAF_UNIXsocket pair inherited at spawn withSOCK_CLOEXECand no filesystem path, removing the impersonation surface; (d) PDU writes to the persistent store remain exclusively in the main process so stream-ordering integrity is preserved. Residual worker-trust model after #318/#319/#323: the worker is trusted to act on the verified identity main forwards (it cannot forge peer credentials, and the worker holds no signing capability at all, per #317 and ADR-0078), but the outboundAuthorizationheader the worker places on its own outbound HTTP requests is still pre-signed in main and carried across IPC — it is our own request-bound X-Matrix signature (bound to the method/url/body/destination of the exact request), not a reusable peer credential, so it carries no harvest/replay value. Relocating outbound signing into the worker is deliberately not done: the worker never signs (ADR-0078), so the pre-signed value crossing IPC is the design, not a gap to close by giving the worker a signing provider.
Main re-verifies the signature of every PDU a worker relays (#450,
resolved in 0.12.13, ADR-0071). Until 0.12.13 main trusted the worker's
signature check: a compromised worker, or a future bug that relayed before
verifying, could have made main persist events impersonating any sender the
room's state authorised. Now homeserver/worker_pool.cpp's
handle_pdu_ingest_request, handle_membership_ingest_request and
handle_invite_ingest_request each verify the signature of the sender's
server (federation::authorize_federation_pdu, the check the worker runs)
with a key main resolves through its own remote_key_resolver, before any
runtime lock is taken. The /send and membership envelopes are rebuilt from
the verified event rather than from the worker's separately framed fields,
and a frame whose event ID disagrees with the signed event is refused. The
worker's PostgreSQL role cannot write the key cache (ADR-0062), so a key
main uses is one main fetched or cached itself. Residual: the frame's
transport origin is still the worker's claim; it only chooses where main
backfills from, and everything fetched there is verified again.
-
Signing capability in the federation worker (v0.10.2, corrected by the security audit 2026-09-29 finding CRY-1 fix, ADR-0078): in Phase 1 the worker loaded the server signing secret from the database, so a compromised worker could forge federation signatures. Phase 2 (v0.10.2) removed the secret from the worker but replaced it with a
sign_request/sign_responseIPC pair that main answered by signing arbitrary bytes with any held key, under the global runtime lock. That did not prevent forgery: a compromised worker could still mint valid signatures as this server (PDUs from any local user, X-Matrix requests, key-server responses), deliver them to any peer over its own outbound network access, and hold main's lock while doing so. The earlier text presented the split as the fix; it fixed key exfiltration only. Since that fix the worker has no signing capability at all: thesign_requestframe,IpcEd25519ProviderandRuntimeStartOptions::signing_overrideare removed, the worker's runtime installscrypto::RefusingEd25519Provider(every sign and verify request fails closed), and main answers anysign_requestframe with an error and no signature without takingruntime.mutex(logged, channel kept, see ADR-0078). The worker had no production need for a signature: outbound X-Matrix requests and invites are signed in main, and/_matrix/key/v2/serveris served by main. Residual: the worker can still relay our own already-signed outbound requests that main hands it (see above), and a compromised worker can still sendsign_requestframes, which are refused inline and logged. -
Operator master key reachable from the federation worker (0.12.13 audit, finding N1; part 1 of 3, this entry updated as later parts land): the worker never held the Matrix signing secret directly (see the entry above), but it opened the same operator master-key file main does — to derive the IPC channel's own auth key — and kept that path in its config for the rest of its lifetime. A compromised worker could therefore read the master key file itself and re-derive every key the master key protects: the signing-secret secret-box key (
crypto::signing_secret_box_key, which decryptsserver_signing_keys.secret_keyat rest) and the access-token HMAC keys (crypto::derive_token_hmac_key[_v3]), even though no worker code path actually reaches the client-server orGET /_matrix/key/v2/serverhandlers that use them today (FederationProxy::handlealways serves/_matrix/key/v2/serverand/_matrix/federation/v1/openid/userinfoon main, and the worker starts with signing disabled, soensure_runtime_server_signing_keyis not called during its start-up) — the exposure was the ability to derive these keys from a file the worker had open, not a reachable code path that used it. Part 1 (this entry) removes the file access at the code level:homeserver::WorkerSupervisor::spawn_and_connectderives the IPC auth key once (inWorkerPool's constructor) and hands the worker only those 32 derived bytes over a second inherited pipe fd (--ipc-key-fd);federation_worker::read_ipc_auth_keyreads exactly that many bytes, requires EOF, andfederation_worker::clear_master_key_fileemptiessecurity.secrets.master_key_filein the worker's own in-memory config copy beforehomeserver::start_runtime()runs, so no code path in the worker — today's or a future one — can open the file even by accident. See ADR-0062.
Part 2, PostgreSQL: closed. A PostgreSQL-backed worker now connects
with a separate, least-privilege login (federation.worker.
database_uri_file, provisioned by packaging/postgresql/
provision-federation-worker-role.sql), handed over a third inherited pipe
fd (homeserver::kWorkerDbUriFd) the same way the IPC auth key is — the
worker never opens that file itself. SET ROLE-based separation was
rejected: a session holding the login role that granted a restricted role
can always RESET ROLE back to it, so only a distinct login credential
closes this. The provisioned role has no SELECT on server_signing_keys
(or the other tables database::table_load_profile_includes excludes), so
a compromised worker with this role cannot read the encrypted secret_key
ciphertext at all, closing the residual gap the part-1 entry above
recorded. Independently, database::TableLoadProfile::federation_worker
(RuntimeStartOptions::database_load_profile, always set by
WorkerEventLoop::run()) means the worker never pulls that ciphertext —
or the other excluded tables' credential material — into its own process
memory even when running in the explicit, CRITICAL-logged
allow_shared_database_credentials=true opt-out mode. See
docs/database-persistence.md, "Federation worker least-privilege role",
for the full table list and the SQL.
SQLite: not applicable, unchanged. A single shared SQLite file offers
no role boundary to separate — the worker keeps opening the same file
there. Part 1's file-access removal is that backend's only protection for
the master key specifically; the load profile above still reduces (but,
for SQLite, does not eliminate — the file itself remains fully readable by
the worker process) how much credential material sits resident in the
worker's memory. Since 0.12.26 (ISO-4) the worker can no longer change
main's SQLite system of record: it opens the file SQLITE_OPEN_READONLY,
refuses a WAL-mode file (whose shared-memory index a read-only connection
would still write), and its Landlock grant on the database directory is
read-only, so even a raw writable open() fails.
Part 3, Linux Landlock: closed. The worker now restricts its own
filesystem access with platform::apply_worker_landlock()
(landlock_create_ruleset/landlock_add_rule/landlock_restrict_self),
called from federation_worker::main() before the worker seccomp filter
and before the event loop opens the database or handles any inbound
request. The ruleset grants only the SQLite database directory (read-only,
required, when database.backend=sqlite) and a fixed set of best-effort,
read-only or read-execute OS-integration paths (CA trust store candidates,
resolver configuration, NSS/dynamic-linker library directories, timezone
data) — never the master key file, either database URI file, or TLS
private keys. A worker compromised through a memory-safety bug — as
opposed to one abusing a code path that intentionally reads a file — can no
longer open any of those directly off disk, closing the gap the entry above
used to record as residual. When the running kernel has no Landlock
support (older than Linux 5.13, or disabled at boot), the worker refuses to
start unless federation.worker.allow_without_landlock=true is set, which
logs CRITICAL on every start — mirroring ADR-0041's fail-closed seccomp
policy; any other Landlock failure is always fatal regardless of that
opt-out. See
ADR-0062,
part 3, for the allowlist derivation and the strace evidence it is based
on.
- Worker sandbox did not cover threads that existed before it was applied (ISO-1,
security-audit-report-2026-09-29.md, closed): seccomp and Landlock attach to the calling
thread only, and the logger's two writer threads were started by the worker's first log
line, before either was installed. A compromised worker thread could aim a signal handler
at one of them (
rt_sigactionandtgkillare allowed) and run with the main process's filter (execve,open,connectallowed) and no Landlock ruleset, reading the master key or the database credentials. The logger now starts no thread until after hardening, every seccomp filter is installed withSECCOMP_FILTER_FLAG_TSYNC, and the self-check reads every task's status instead of only the thread-group leader. See ADR-0082. Residual: a thread that exists before hardening is confined by seccomp (TSYNC) but not by Landlock, which has no thread-sync flag on older kernels; the ordering rule is what prevents it. - A compromised worker could stop or starve the main process (ISO-2,
security-audit-report-2026-09-29.md, closed): the worker filter allowed
kill,tkill,setrlimitandprlimit64with no argument checks, and the worker runs under main's uid, sokill(getppid(), SIGSTOP)froze main without a crash (noRestart=on-failure) andprlimit64could lower main's limits. Those syscalls are gone from the worker filter,tgkillis allowed only for the worker's own thread group, and Landlock ABI 6 kernels also scope signals. Residual: the worker shares main's uid, so the seccomp argument check is the only barrier on kernels before Landlock ABI 6. See ADR-0112. - A compromised worker could open any socket family (ISO-8,
security-audit-report-2026-09-29.md, closed):
socket()andsocketpair()were unrestricted, exposing kernel interfaces such asAF_ALGandAF_PACKET. The worker filter now admitsAF_UNIX,AF_INETandAF_INET6, answersAF_NETLINKwithEAFNOSUPPORT(glibc's address-sorting probe falls back) and kills on anything else; the systemd unit restricts the same families for the whole service. Residual: Landlock does not governconnect()to a pathnameAF_UNIXsocket, so the worker can reach any local socket its uid may open; a PostgreSQL role usingpeerauthentication would accept it. See ADR-0147. - A compromised worker could exhaust main's memory through IPC (CRY-2, security-audit-report-2026-09-29.md, closed): main's reader thread queued every request frame for the dispatch thread without limit, ahead of the in-flight cap of ADR-0065. The queue is now capped by frames and bytes; a worker past either has its channel closed and is replaced. See ADR-0111.
-
SIGPIPE terminated the server (HTTP-5, closed): OpenSSL's socket BIO writes without
MSG_NOSIGNAL, so a TLS client that reset its connection after the handshake killed the process on the 408 write. Every executable now ignores SIGPIPE first thing inmain(). -
Single worker as a chokepoint (v0.10.3, mitigated in v0.10.4): Phase 1 used one federation worker for every room. A CPU-heavy room could still delay federation traffic for all other rooms because that single process had to process every inbound PDU. Phase 3 shards rooms across N independent
merovingian-fed-workerprocesses usingfnv1a_32(room_id) % shards; non-room requests route to shard 0. A crash or resource exhaustion in one shard only affects the rooms owned by that shard. As of v0.10.4 the out-of-process worker is mandatory and there is nofallback_in_processoption; startup fails closed if the worker pool cannot be created. This does not move every federation operation out of main: request verification, designated local routes, EDU-only sends, completed retries and failed room snapshot reloads have explicit main-process paths inFederationProxy. TheWorkerSupervisorrestarts crashed workers automatically with exponential back-off. -
Unbounded client-supplied
vialist drove unbounded thread spawning and unbounded join latency (v0.10.11):POST /join'svia/server_namequery parameters are attacker/client-controlled and were passed straight through to the parallel make_join race (v0.10.10) with no upper bound. Every candidate was spawned as an OS thread immediately viastd::launch::async— only throttled to run byjoin_parallelism, not to spawn — so a client (or a room whose federation state legitimately spans dozens of servers) could make the server spin up one thread per via entry on every join attempt. The same unbounded candidate count meant the race had no upper bound on wall-clock time either: withjoin_parallelismconcurrent slots and up tojoin_timeoutper candidate, total race time scaled with candidate count and could run for many minutes — long after the calling client's own HTTP request (and any reverse proxy in front) had already timed out, so the client observed a generic fetch failure while the server kept working unseen. Fixed by two independent bounds:security.federation.join_max_candidates(default20) truncates the ordered candidate list to the first N entries before anystd::asynctask is spawned, capping upfront thread creation regardless ofvialist size;security.federation.join_race_deadline(default45s) bounds the entire race's wall-clock time independent of the per-candidatejoin_timeout, sojoin_roomalways returns a definitive response within a bounded window. Candidates still in flight when either bound is hit are parked in the existingorphan_futures_background-drain queue, unchanged from the losing-candidate path. -
send_joinstate and auth_chain events were persisted with no signature verification (v0.10.11):join_room's ingestion of asend_joinresponse'sstateandauth_chainarrays parsed each event and wrote it straight to the persistent event graph, trusting the resident server's response wholesale — no Ed25519 signature check, no remote signing-key fetch, in direct violation ofsrc/federation/AGENTS.mdrule 2 ("Verify every inbound PDU's signature... Unverified events must be silently dropped"). A resident server (or an attacker able to act as one, e.g. via DNS/BGP hijack of a room's resident server) could inject arbitrary membership or state events into a joining server's view of the room with no cryptographic check. Fixed byfilter_verified_send_join_events: distinct(sender_domain, key_id)pairs across both arrays are resolved via the existingremote_key_resolver/key-cache infrastructure (bounded concurrency,security.federation.join_state_key_parallelism, default100), and each event's signature is verified against its resolved key before being handed toingest_send_join_state/ the auth_chain persistence loop. Fail-closed: an event whose key cannot be resolved or whose signature does not verify is silently dropped, not persisted, and does not fail the join. As first shipped, this fix left two holes, closed in FED-1 below. -
send_joinresponse events were not bound to the joined room, and own-domain events skipped the signature check (FED-1, security audit 2026-09-29, critical): the 0.10.11 fix above kept every event whose sender was on our own server without any signature check, and nothing compared an event'sroom_idwith the room being joined.ingest_send_join_statestored each event under its ownroom_idand wrote it to that room'scurrent_state, and the auth-chain loop did the same for auth-chain events. A local user who controlled a remote server could join a room there and have itssend_joinresponse carry an unsignedm.room.power_levelsfor any room hosted here, naming a local sender — and become that room's admin, or forge memberships through the background member fill. Fixed (ADR-0083):filter_send_join_events_for_roomdrops everystateandauth_chainentry that does not belong to the joined room before any key resolution, on both the synchronous and the background path (for v12, them.room.createevent belongs only when its derived room ID is the joined room), andingest_send_join_staterepeats the check at the writer. Own-domain events are verified against this server's own signing keys — the current key and every retired key it still holds, never fetched over the network — and are dropped when the key ID is not one we hold or the signature fails. Those key rows cannot be replaced from outside: the remote-key resolver never fetches or caches keys for our own server name (a relayed PDU can name our domain as its sender), andstore_server_signing_keyrefuses a secretless write that would change the public key of a secret-holding row. Auth-chain events are stored as outliers with nocurrent_staterow, and the start-uprepair_missing_state_entriespass only promotesacceptedevents, so an outlier cannot become current state after a restart either.
What this guarantees, and what it does not: a send_join response can only
write the state of the room being joined, and only with events whose
signatures verify. It does not make the resident server honest about that
room. The state array is not run through the authorisation rules, so a
resident server can still hand us a self-consistent but false view of its
own room — for example, omit a ban or supply an earlier, validly signed
power-levels event as current. Such a view is confined to the joined room;
events that reach us later go through normal PDU ingestion, which
authorises them and resolves state against it as usual.
-
Fast join / partial-state trade-off (v0.10.11): verifying a large room's full
statearray before returningjoin_room's response means the client waits on resolving a signing key for every distinct member home server — potentially hundreds for a 30,000-member room — even though only a handful of room-level state events (create, power_levels, join_rules, history_visibility, our own membership) are actually needed for the room to be usable.split_send_join_state_eventsseparates those from every other member'sm.room.memberevent; the former is verified and persisted synchronously (the join response does not return until this completes), the latter is verified and persisted by a background task tracked in the existingorphan_futures_queue (same drain-on-shutdown guarantee as a losing make_join race candidate). The verify-before-persist invariant is unchanged for every event, including deferred ones — nothing enters the event graph without a checked signature, and/synconly ever reads persisted rows, so no unverified data is exposed to clients regardless of timing. The trade-off is a "partial state" window:room_has_member(), the/membersendpoint, and theLocalRoom.memberscache may not list every member until the background task completes (room.join.background_state_completelogs when it does), which fails closed (a not-yet-backfilled member looks absent, not present) but can surface as a temporarily incomplete member list to the joining client. This is deliberately narrower than Synapse's full "faster joins" (MSC2775-derived) implementation — there is no explicit device-list-change gating, resync coordination, or blocking of specific operations while partial: it is a bounded-scope version of the same idea, suitable because critical auth-relevant state is never deferred. -
Remote media fetch fabricated a scanner-clean verdict for federated content (2026-07 audit):
fetch_remote_media_live()unconditionally setscanner_clean=trueon every response fetched from a remote origin server, bypassing the AV-scanner gate for attacker-controlled federated media wheneversecurity.media.remote_fetch_enabledis on. Fixed by reportingscanner_clean=false(there is no real scanner verdict for remote content, same as local uploads today — seemedia-repository.md) and introducingMediaAcceptancePolicy(allow/allow-after-scan/quarantine/deny), configured independently for local uploads (security.media.local_upload_policy, defaultallow-after-scan, preserving prior behaviour) and remote fetches (security.media.remote_fetch_media_policy, defaultquarantine, since federated content has no accountable local uploader and no real scan ever occurs).decoder_marked_safeis deliberately lefttruefor remote content:unsafe_decoders_disabledhas no config knob today, so flipping it would hard-reject every remote fetch in every deployment rather than fail safely — tracked as a follow-up alongside real AV-scanner integration. - Remote media download URL omitted the server-name path segment
(2026-07 audit): the outbound federation media fetch built
/_matrix/media/v3/download/{mediaId}instead of the spec-required/_matrix/media/v3/download/{serverName}/{mediaId}(server-server-api.md#get_matrixmediav3downloadservernamemediaid), and neither segment was percent-encoded. Fixed byremote_media_download_url()building the correct two-segment path with bothorigin_serverandmedia_idpassed throughcore::percent_encode_path_component(), so a reserved character in either cannot be misread as an extra path segment or a different route on the resolved host. - Trusted-proxy X-Forwarded-For accepted unvalidated pseudo-IP values
(2026-07 audit): the client-server rate limiter used the leftmost
non-empty
X-Forwarded-Forvalue verbatim as the rate-limit key whenever the direct peer was a configured trusted proxy, with no check that it was a syntactically valid IP address. An attacker able to reach a trusted proxy (or a proxy that fails to overwrite an inbound header) could rotate through malformed strings to mint a fresh bucket per request, defeating brute-force protection on/login,/register, and every other rate-limited endpoint. Fixed by validating the candidate with the newfederation::ip_address_is_valid()(strictinet_pton-based IPv4/IPv6 literal check) before trusting it; a missing or malformed value falls back to the direct peer address instead. - A client choosing its own rate-limit bucket through X-Forwarded-For
(0.12.13 audit item 4, ADR-0073): the limiter still used the leftmost
entry, which the client controls whenever a trusted proxy appends to the
header rather than overwriting it, so a client could prefix a fresh valid
address to each request and never be limited. It now walks the list from
the right past trusted proxies, and reads every header line as one list.
In the same change IPv6 clients are grouped by
server.http.ipv6_client_prefix_length(default /64), since one site is handed a whole /64 and could otherwise rotate addresses within it. - Admin media routes accepted unsanitized media IDs from the raw path
suffix (2026-07 audit): the
/_merovingian/admin/media/{quarantine, release,remove}routes passed the raw path suffix directly as the media ID, unlike the download/thumbnail routes (local_media_download_parts()), which strip query strings and reject embedded slashes. A request like.../remove/<id>?reason=xtreated the query string as part of the media ID, so no record matched it and the intended object was silently left untouched instead of acted on. Fixed byadmin_media_id_from_suffix(), which strips any query string and rejects an empty ID, an embedded/, a..traversal sequence, or an embedded space before the ID reaches the admin action. - Self-leave authorized regardless of current membership — ban evasion
(2026-07 audit):
authorize_event_against_auth_eventsallowed a self-leave (membership: "leave", sender matches state_key) unconditionally, with a comment noting "unless banned in some room versions" that was never implemented. A banned user could send a self-leave event to flip their own membership frombantoleave, then knock or rejoin under normal join rules. Fixed by requiring the sender's current membership beinvite,join, orknockbefore a self-leave is authorized, per the room v10-v12 authorization rules. - Unrecognized
membershipvalue silently treated asleave(2026-07 audit):parse_membership_statefell through toMembershipState::leavefor any string outside the five defined values, contrary to the spec's "Otherwise, the membership is unknown. Reject." Combined with the self-leave bug above, a malformedmembershipvalue could be admitted into room state under the guise of a "leave". Fixed by rejecting an unrecognizedmembershipvalue on the event under authorization, while internal lookups of already-accepted prior state still fall back toleave(the safe "not a member" default). - Federation PDUs verified against an expired signing key (2026-07
audit):
remote_key_cache.cpp's resolver deliberately falls back to a stale cached key when a live refresh fails, so callers can distinguish "known but unreachable" from "never seen" — butauthorize_federation_pdunever checked the returned key'svalid_until_tsbefore using it to verify a PDU's Ed25519 signature. If a remote server's key was rotated after a compromise and the old server became unreachable, an attacker holding the old private key could keep forging PDU signatures indefinitely. Fixed by rejecting PDUs verified against a key whosevalid_until_tshas passed as of the request'snow_ts. Superseded in 0.12.13 by ADR-0075: that check compared with the current time, which the spec does not ask for (it rejected valid historical events, and v1-v4 events on a field the spec says MUST be ignored), and backfill skipped it. From room v5 a key must now be valid at the event's ownorigin_server_ts, and a fetched key is cached for at most 7 days (min(valid_until_ts, fetched_at + 7 days), rooms/v5.md). Residual:origin_server_tsis chosen by the sender, so someone holding an old private key can still sign events backdated to within that key's capped validity while its server is unreachable. The spec accepts this; such events still have to pass the auth rules against the state before them and against current state. - Media content-sniffing was a no-op, defeating the declared/actual MIME
mismatch quarantine (2026-07 audit):
client_server.cppbuilt the internal upload pipe body by copying the client-declaredContent-Typeinto both the "declared" and "sniffed" MIME fields, soevaluate_media_upload's mismatch check always compared a value to itself. An attacker could upload arbitrary content (e.g. HTML with an embedded<script>) while declaring an allow-listed type such asimage/png. Fixed by addingmedia::sniff_mime_type()(magic-byte detection plus a printable-ASCII heuristic fortext/plain) and sniffing the real bytes for both local uploads (client_server.cpp) and federated media fetches (repository.cpp'sfetch_remote_media). - Unsanitized
Content-Typeheader allowed field injection into the internal media pipe protocol (2026-07 audit): the internaldeclared_mime|sniffed_mime|scanner_clean|bytesformat relies on|as a field delimiter, buthttp::header_value_is_valid()permits|in header values. A client-controlledContent-Typecontaining|could shift the parsed field boundaries and forge thescanner_cleanflag and leading body bytes seen downstream. Fixed by rejecting a media upload whoseContent-Typecontains|with400 M_BAD_REQUESTbefore the pipe body is constructed. - Registration token leaked into structured logs via an unredacted
tokenquery parameter (2026-07 audit):contains_sensitive_markerrecognizedaccess_token/refresh_token/session_tokenbut never the bare keytoken, andGET /_matrix/client/v1/register/m.login.registration_token/ validity?token=<secret>passes the plaintext registration token under exactly that key. Every request target is logged viasanitized_http_target, so the raw secret reached structured logs in cleartext. Fixed by addingtokento the exact-match redaction list. That earlier fix did not protect durable audit targets (OPS-2): rejection paths still passed raw request targets to the shared audit sink. Audit targets now retain only the bounded path/identifier, never a query string, across durable/in-memory storage andaudit.appenddebug output (ADR-0136). Actual rate-limit and unavailable-runtime requests are covered with SQLite reopen and debug-output regressions. Historical rows and logs are not retroactively scrubbed; arbitrary credentials embedded in paths or other audit fields remain forbidden. /refreshgated behind an access token it does not need (2026-07 audit):client_auth_endpoint_requires_access_tokenexcluded onlyloginandregister_account, so a request to refresh an expired access token — the case the endpoint exists to handle — could be rejected before the refresh token in the body was ever inspected, contrary to spec ("this endpoint does not require authentication via an access token"). Fixed by excludingrefresh_tokenfrom the access-token requirement.- Accepted client sockets leaked into forked worker subprocesses (2026-07
audit):
http_server.cpp's plain-HTTP and TLS accept loops used::accept()instead ofaccept4(..., SOCK_CLOEXEC), unlike every other fd-creation site in the codebase. Because federation workers and the thumbnail worker are spawned viaposix_spawn/fork()while client connections (including long-poll/sync) remain open, an accepted socket withoutFD_CLOEXECwas inherited by every subsequently spawned worker for as long as the connection stayed open. Fixed by usingaccept4(..., SOCK_CLOEXEC)in both accept loops. - Canonical JSON serializer had no float guard on the signing/hashing path
(2026-07 audit):
serialize_canonical()usedstd::to_string(double)for float formatting — fixed to 6 fractional digits, not shortest round-trip — so a small magnitude like1e-7silently corrupted to"0.0". Unreachable from event signing today because every signing caller parses withparse_lossless(), which rejects floats at the parse boundary, but the serializer itself had no independent guard. Fixed by splitting intoserialize_canonical()(floats still permitted, now via a portable shortest-round-tripping conversion, for ordinary never-signed responses likem.tagorder) andserialize_canonical_strict()(rejects any float withCanonicalJsonError::float_not_allowed), withevent_signer.cpp,event_id.cpp, andsignable.cppswitched to the strict entry point. -
Master key material and its derived keys held in unwiped, unmlocked memory (2026-07 audit):
load_master_key_material()— the root secret every derived key (secret-box, access-token HMAC, IPC auth) comes from — was read into a plainstd::vectorand an unwiped stack buffer, neither zeroised normlocked, leaving it recoverable from a core dump, an unrelated arbitrary-read bug, or a swapped page. The three 32-byte derived keys (SecretBoxKey,TokenHmacKey,IpcAuthKey) wrapped their bytes in a barestd::arraywith no destructor. The original fix read roots intocore::SecretBufferand wiped derived arrays; the September audit identified that shared heap pages could still be unlocked by a neighbouring allocation's destruction. The 0.12.26 fix (CRY-4) uses isolated sodium allocations for roots and every derived-key/cached-key copy, with kernel lock checks in Linux tests. Cached-key copy failures return no key instead of throwing or downgrading token issuance (CRY-6). The access-token keys are cached once per master key file and shared by every request (crypto::cached_token_hmac_keys), so an authenticated request no longer makes fresh locked copies that could exhaustRLIMIT_MEMLOCKunder load, and a moved-from key cannot be copied into a valid all-zero key. Root files require at least 32 bytes and reject all-zero material (CRY-3), without silently changing persisted derivations (ADR-0128). Validation-session lookup and consumption use the variable-length constant-time helper (CRY-5). Descriptor-bound text secret readers validate mode, ownership and type and refuse symlinked final components (ISO-7). The TLS private key is read the same way and handed to OpenSSL from memory, and every master-key derived-key cache takes the file identity and the key material from one descriptor (crypto::open_master_key_file), so a cached key is always the one derived from the file its identity describes.src/homeserver/room_service.cpp's duplicate root loader remains removed in favor of the singlesrc/crypto/implementation. -
Outbound identity-server
store-inviteSSRF and trust-bypass surface (v0.11.9): inviting a user by a 3PID requires the homeserver to call a remote Identity Service APIstore-inviteendpoint, introducing a new outbound attack surface: a client-controlledid_servercould direct the homeserver to an arbitrary or internal host (SSRF), an untrusted IS could be used to mint unverifiable invites, and federation signing-key material could be exposed to the IS. Mitigations, all fail-closed: (1) SSRF — every IS outbound call resolves throughfederation::CachedServerDiscovery(ServerDiscoveryNetwork::lookup_addresses) with the resolved addresses pinned anddeny_ip_rangesadding operator-configured IPv4/IPv6 literal/CIDR blocks (including public ranges) to the mandatory built-in private/loopback blocks (127.0.0.1, RFC1918, etc.); there is no ad-hoc DNS lookup on the client-supplied host. (2) Trust gate — theid_servermust be listed inserver.identity_server.trusted_serversor the invite fails closed with403, so a client cannot direct the HS to an arbitrary or internal host under an untrusted IS identity. (3) Auth model — the HS acts as a client of the IS and authenticates with a bearerid_access_token, not anX-Matrixfederation signature, so no server signing-key material is exposed to the IS. (4) Fail-closed transport — an IS transport error, non-200 response, or malformed body returns502to the caller with no local-only fallback that would mint an invite the HS cannot later verify. (5) No lock held across network I/O — IS network calls are made outsideruntime.mutexper the codebase convention, so a slow or hostile IS cannot block other runtime work. -
3PID ownership claimed without proof (AUTH-5): before AUTH-5
requestTokenwithout an identity server created a validation session that was already marked validated and sent nothing, so any authenticated user could bind an email address or phone number they did not own, and the real owner was then refusedM_THREEPID_IN_USE. Mitigation: ownership is proven only by a trusted identity server. Sessions start unvalidated and/account/3pid/add,/bindand/account/3pidask the identity server (getValidated3pid) whether the same medium and address were validated; without a trusted identity serverrequestTokenis refusedM_THREEPID_MEDIUM_NOT_SUPPORTED. Residual: the session keeps the caller's identity-server access token in memory for at most the 15-minute session TTL (overwritten when the session is dropped, never persisted or logged); a trusted identity server that lies about validation can still vouch for an address, which is what trusting it means. -
IS-delegated
bind/unbind/requestTokenand storedclient_secret/sid(v0.11.10): delegatingbind,unbind, andrequestTokento a remote IS extends the v0.11.9 outbound surface, and unbind auth mode 2 requires the HS to persist the IS-issuedclient_secret+sidso it can replay them in a later unbind body. New risks and mitigations, all fail-closed: (1) DB compromise widens 3PID control — an attacker who readsaccount_threepidsgains theclient_secret+sidfor every IS-bound 3PID and can call the IS/3pid/unbinddirectly, detaching 3PIDs for affected users without the user's password. Mitigation: the columns are populated only for IS-bound 3PIDs (local-only bindings stay empty), they sit behind the same DB-role gating as the rest of the account store (themerovingian_runtimerole reads/writes them; the migration role does not), and an incident response rotates/revokes affected IS sessions the same way access-token hashes are rotated on a DB leak — there is no plaintext token material here, only IS re-unbind secrets. (2) Orphaned IS-side bindings on premature local removal — silently deleting the local record after a transport failure would drop the storedclient_secret/sid, making a retry impossible while the IS still holds the binding. Mitigation: unbind fails closed with502on transport error and on unrecognised non-2xx IS responses, so the local record — and itsclient_secret/sid— survives until the IS confirms or the user retries. (3) Trust-set change stranding — an operator removing an IS fromtrusted_serversmust not strand a 3PID the user can no longer unbind. Mitigation: when the storedid_serveris no longer trusted, unbind reportsid_server_unbind_result="no-support"and still removes the local record, so the user is never stuck by an operator trust change. (4) SSRF / trust / auth-model / fail-closed transport / no-lock-across-I/O — the same five v0.11.9 controls apply unchanged: all three handlers resolve viaCachedServerDiscoverywithdeny_ip_ranges, gate ontrusted_servers, authenticate with a bearerid_access_token(except the unauthenticated mode-2 unbind body), fail closed with502/403, and releaseruntime.mutexfor the network call. (v0.12.9)client_secretandsidare additionally bound as sensitive prepared-statement values, so they never reach a query trace or diagnostic log — closing the leak path that did not require a DB read at all. They remain plaintext at rest: that is the residual risk this entry documents, and removing it means either encrypting the column or moving to homeserver-signed unbind (auth mode 1), both of which are schema/protocol changes rather than a binding fix. -
Outbound email for email pushers (0.12.44, ADR-0219, ADR-0220): the relay is the operator's (
server.email.smtp_url), but the recipient comes from a client'spushkey. Threats and mitigations: (1) open relay — an account naming a stranger's address.POST /pushers/setaccepts only an email 3PID the account already holds, and the email job checks again before each send, deleting a pusher whose address has left the account; a deactivated account is never mailed. (2) SMTP command and header injection — every address and the server name are checked (email::is_safe_mailbox_address) and refused, never repaired, before they reach libcurl or a header. (3) disclosure — an email carries only the unread count and the server name, never room, sender or message content. (4) cleartext interception and STARTTLS stripping —smtp://requires STARTTLS (CURLUSESSL_ALL), verification is on, TLS 1.2 minimum; cleartext is allowed only to a loopback IP literal by explicitrequire_tls=false, and never with credentials. (5) credential exposure — the SMTP password is in an owner-only secret file read for each send; it never enters the config file, logs, or the federation worker, whose config copy hasserver.emailcleared; and the worker's Landlock secret-path guard (worker_landlock_secret_paths) refuses to start the worker if a rule, such as the SQLite database directory grant, would cover the password file. (6) flooding — one delayed, throttled digest per pusher, a bounded in-memory queue, and the per-user pusher cap. Residual: libcurl's copy of the password is freed but not wiped; a relay with a private CA is not supported (system trust store only). -
Outbound Push Gateway
notifySSRF surface (v0.11.11, routed): a pusher's gateway URL (data.urlonPOST /_matrix/client/v3/pushers/set) is supplied entirely by the registering client — unlike the identity-server URL above, there is no operator allowlist for push gateways; any Matrix client can point a pusher at any host.merovingian::push::PushGatewayClient::notify()is therefore treated as hostile input from construction and fails closed the same way the identity-server and federation outbound paths do: (1) SSRF — the gateway host is resolved throughfederation::CachedServerDiscovery(ServerDiscoveryNetwork::lookup_addresses), which applies the operator'sdeny_ip_ranges(private/loopback ranges rejected) before returning pinned addresses; the client never performs its own DNS lookup and never hands the transport a client-supplied address directly —http::OutboundClientbinds the connection to the pinned address viaCURLOPT_RESOLVE. (2) URL shape gate — the gateway URL is independently re-validated ashttps://with a path of exactly/_matrix/push/v1/notifybefore any resolution is attempted (mirroring the registration-time check inclient_server.cpp'smatrix_pusher_url_is_valid), so this module does not rely solely on upstream validation holding. (3) Config gate, disabled by default, enforced at both call sites —notify()checksconfig::PushConfig::enabledbefore doing anything else, androom_service.cpp'sbuild_pending_push_deliveries()re-checks the same flag before reading a single pusher row or building a gateway-boundPendingPushDelivery(rule evaluation andGET /_matrix/client/v3/notificationshistory recording now run unconditionally, ahead of this check — see the "Notification history" entry below — but that is a local, in-process read/write with no network reach; the gate that decides whether a byte can leave the process is unchanged), so a deployment that has not opted into gateway delivery still pays no DNS lookup, connection, or outbound byte per sent event. (4) No lock held across network I/O — like the identity and federation clients,PushGatewayClientholds no lock of its own, and the routing that calls it (room_service.cpp'srun_pending_push_deliveries) never holdsruntime.mutexwhile it runs: gateway calls happen entirely on a detachedstd::asynctask parked inHomeserverRuntime::orphan_futures_(the same mechanismjoin_room's background member-fill task uses — seearchitecture.md), so a slow or unreachable gateway cannot block or fail the client-server request that triggered the notification. (5) Rejection handling stays narrow — arejectedpushkey deletes exactly the one(user_id, app_id, pushkey)row that pusher call targeted (one HTTP request is sent per pusher, never a bundled multi-device request), so a malicious or buggy gateway response cannot cause deletion of a pusher it was never asked to notify.runtime.mutexis re-acquired only for the duration of that singledelete_pushercall, mirroring the identity-client pattern of dropping the lock for the network round trip and re-acquiring it only to persist the outcome. -
Notification history storage (v0.11.11, routed):
GET /_matrix/client/v3/notificationsneeds history to serve, sobuild_pending_push_deliveries()records onePersistentNotificationrow (database::store_notification) per local recipient whose push rule evaluation resolvesnotify: true— deliberately unconditional onpush.enabledand on the recipient having any pusher at all (the endpoint returns events the user "has been, or would have been, notified about"; a user with push turned off must still see this history). This is a same-server, in-memory-plus-local-database write triggered by an event the requesting user was already authorized to see (the recipient is already a room member or the invite target) — it opens no new network path and adds no new trust boundary. Two properties bound its own resource cost: (1) per-user retention —store_notificationprunes the oldest rows for thatuser_idbeyondserver.client_api.max_notifications_retained_per_user(default 1,000; finite range 1..100,000) after every insert, so the table cannot grow without bound under sustained message volume, mirroring the fix applied toorphan_futures_below; (2) ignore-list suppression applies first — a notification is recorded only after the sametrust_safety::is_delivery_suppressedcheck the gateway path uses, so an event from a sender the recipient has ignored is invisible toGET /notificationsexactly as it is to Push Gateway delivery, not a separate code path that could drift out of sync. -
Push delivery background tasks were unbounded (v0.11.11, fixed):
dispatch_push_deliveriesparked onestd::asyncfuture inHomeserverRuntime::orphan_futures_per qualifying event and never reaped a completed one, unlikejoin_room's make_join race, which already reaps before parking (seearchitecture.md). Withpush.enabled = true, this was an unbounded memory leak proportional to message volume (oneorphan_futures_entry per event, forever) and unbounded thread creation — a busy room, or a client sending many events in a burst, drove one OS thread per event with no ceiling. Fixed by two changes shared with the join-race path via a single helper:reap_completed_futures()removes every already-finished future fromorphan_futures_(via a non-blockingwait_for(0s), never.get()/.wait()on a still-running one) before a new one is parked, and a dedicated counter,HomeserverRuntime::push_delivery_in_flight_(astd::atomic<std::size_t>, tracked separately from the shared vector's total size so a large join race cannot starve push delivery or vice versa — see the deadlock note below for why it is atomic rather than mutex-guarded), is checked against a validated configurable cap,server.push.max_in_flight_deliveries(256 by default,room_service.cpp) before a task is spawned. At capacity the delivery is dropped — never spawned, never blocked on — and a warning is logged: a missed push is recoverable (the client still sees the event on its next/sync), an exhausted thread pool is not. This is the same "bound all resources, fail closed toward availability" trade-off as thevia-list bound above. OUT-3 additionally splits each event into independently admitted recipient batches. One recipient is capped atG > 1 ? min(8, G - 1) : 1running batches, preserving the global bound while leaving parallel headroom whenG > 1. A move-only guard releases both atomic counters on completion or unwind, and future parking storage is reserved before launch.G=1retains serial dropping; multiple recipients may still fill the process budget. The real TLS starvation regression and related push cases pass; independent source review passed with no security or logic findings. Fresh full acceptance passed 56 targets with all return codes zero, no failures/timeouts and 170 unchanged files including the reviewed sources. Gateway circuits, registration quotas and general fairness are not supplied by this bounded repair. See ADR-0140. -
Closing a shared push gateway for everyone (PR #509 review, 0.12.26): the OUT-3 gateway circuit is keyed by host, and one host (Sygnal for a popular app) serves many users. Counting 5xx answers let one user register a pusher with bad data on that host and open its circuit for every user for the whole cooldown. Only a failure to reach the host (DNS, connection, TLS, timeout) counts now; the key is the case-folded
host:port, so spellings of one host cannot dodge each other's circuit; and arejectedlist removes only the pushers whose pushkeys it names. See ADR-0151. -
Sync pool exhaustion by one account (audit HTTP-4, 0.12.16): v3 and sliding sync share transport admission: four live waits per account, two per device, and at most 32 globally. Smaller pools reduce those limits. Queued work holds a slot until response or disconnect, and failed pool submission returns 429 rather than blocking the main request pool. The 120-second timeout ceiling also saturates oversized decimal inputs safely. See ADR-0091.
-
Forged room creator identity (audit EVT-7, 0.12.16): Room versions through v10 use create
content.creator; v11 and later use the create sender and ignore a legacy creator field. Bootstrap membership must reference the create event as its sole predecessor. Creator power cannot substitute for joined membership on ordinary events. -
Media privacy and removal across restart (audit MED-1/MED-3, 0.12.16): Hydration preserves the persisted legacy-access flag; records default to private. Re-uploads revive one storage identity rather than duplicating it, and removal writes cleared bytes to the durable tombstone. These controls prevent restart from exposing private uploads or losing moderation state.
-
Remote quarantine bypass (audit MED-2, 0.12.16): Held remote records cannot be served or passed to thumbnail processing. Their download result contains no bytes and returns 451; only admitted 200 results can be converted to payload responses. Unexpected internal success statuses return a fixed error rather than serializing their body.
-
Lost worker supervision after a failed restart (audit ISO-3, 0.12.16): Supervisors wait only on their own positive child PID and keep retrying failed spawns. Restart delay remains exponential until 30 seconds of sustained child and IPC health, with interruptible waits during shutdown. See ADR-0096.
-
Membership transitions never reached push delivery (v0.11.11, fixed): delivery previously fired only from
send_event()(/sendand/statePUTs); the membership-mutating endpoints (invite/join/leave/kick/ban, and the 3PID invite) each go through their own dedicated functions inroom_service.cppand never calledbuild_pending_push_deliveries()/dispatch_push_deliveries()at all. The concrete consequence: the default, enabled-by-default rule.m.rule.invite_for_me— whose entire purpose is to notify a user they were invited — could never fire, since nothing ever evaluated push rules for a membership event. Fixed by routing every membership transition through the same pipeline (dispatch_membership_push_notification(), called frompersist_membership_transition— the shared helper behind invite/ban/ kick/leave/knock — and fromjoin_roomandinvite_user_by_threepiddirectly). The one correctness subtlety:build_pending_push_deliveries()only evaluates rules forLocalRoom::members, i.e. joined members, but an invitee is by definition not yet joined (apply_runtime_membershiponly adds tomemberson"join"), so an invite target was invisible to the old membership-only loop even where the loop was reachable.build_pending_push_deliveries()now accepts anextra_recipientsspan for exactly this case; entries equal to the sender or already a joined member are silently absorbed, so every call site can pass the membership target unconditionally. This is a same-server-only, in-memory routing change — it does not alter the SSRF/URL/config gates above, all of which still apply per delivery. -
The in-flight counter above deadlocked runtime shutdown (v0.11.11, fixed):
push_delivery_in_flight_was originally a plainstd::size_t, and the background task decremented it as its final action while holdingorphan_futures_mutex_.HomeserverRuntime::~HomeserverRuntime()(and the integration test helperwait_for_background_tasks()) held that same mutex for their entire drain, including the blockingfuture.wait()calls on every parked future. A destructor that already held the mutex could never see the task finish, because the task could never acquire the mutex it needed to finish first — deadlock. Push delivery is disabled by default so no running deployment was exposed, but any deployment that enables it would hang indefinitely on shutdown with a delivery still in flight. Fixed two ways:push_delivery_in_flight_is now astd::atomic<std::size_t>, so the background task's decrement never takesorphan_futures_mutex_at all (the dispatcher-side check-and-increment still does, since that read-modify-write against the cap must stay correct across concurrent dispatchers); and both waiters now holdorphan_futures_mutex_only long enough to move the parked futures out oforphan_futures_, waiting on the moved-out copies with the mutex released. This also means a runtime shutdown no longer blocks a concurrentdispatch_push_deliveriescall trying to reap or park a future while the drain is in progress. Every other site that parks or reapsorphan_futures_(the make_join race inroom_service.cpp'sjoin_room) was audited and does not wait on a future while holding the mutex — this failure mode was unique to the two fixed call sites. -
m.ignored_user_listwas storable but never enforced (v0.11.11, fixed): a client could set the account-data key (account data is generic storage) but the server never filtered anything by it — every /sync, /messages, /context, sliding sync, and push-notification response ignored it entirely, so a user who ignored an abuser kept receiving that abuser's messages, invites, and push notifications regardless. This is a safety-relevant gap for a homeserver whose stated design goal is user safety: the only client-facing control a harassed user has short of leaving a room or blocking at the OS/network level did nothing. Fixed bymerovingian::trust_safety::ignore_list— a single shared predicate (is_delivery_suppressed) called from every delivery surface:GET /sync(timeline, invite list, ephemeral typing/receipts), MSC4186 sliding sync (timeline, required_state, receipts/typing extensions),GET /messages,GET /context/{eventId}, andbuild_pending_push_deliveries()(message and membership/invite push). Scope, deliberately narrow per spec: this is a delivery-side filter only — it does not touch event persistence, authorization, state resolution, or federation acceptance (an ignored user's events are still fully valid room state, exactly as the spec requires: "Servers must still send state events sent by ignored users to clients"), it does not hide history already delivered to the client, and it does not stop the ignored user from continuing to send into a shared room — seedocs/trust-safety.md"What ignoring a user does NOT protect against" for the full boundary. A malformed or absentm.ignored_user_listfails safe to "nothing ignored" (parse_ignored_user_listnever throws and treats any parse failure as an empty set) rather than either erroring or over-suppressing. -
Federated events never reached push delivery (v0.11.11, fixed): the fixes above wired delivery into
send_event()(locally composed events) and every membership-mutating path, but an event accepted over federation persists through a different function,ingest_pdu_event(), called from theruntime.federation.pdu_sinkcallback — which only ever published the sync token and never calledbuild_pending_push_deliveries()/dispatch_push_deliveries(). The practical consequence: a message from a remote room member, the ordinary case for any room with federated membership, produced no/notificationshistory row and no Push Gateway request for a local recipient — push notifications silently did not work for federated rooms at all. Fixed withdeliver_federation_push_ notifications()(room_service.cpp), called from thepdu_sinklambda (local_http_router.cpp'swire_federation_callbacks_impl) immediately after a PDU is accepted. This lambda is the single convergence point for every accepted PDU regardless of origin — both the direct main-process federation path (inbound_request.cppcallingruntime.pdu_sinksynchronously) and the worker-relayed path (worker_pool.cpp'spdu_ingestIPC handler calling the sameruntime_.federation.pdu_sinkon main) — so wiring the call in exactly one place cannot result in a PDU being delivered twice. Reusesbuild_pending_push_deliveries/dispatch_push_deliveriesunchanged rather than a second delivery implementation that could drift from the local one; carries the samepush.enabledgate, the same off-request-pathstd::asyncdispatch, and the same configurable push-delivery cap as the local path. -
Unbounded pushers per recipient (v0.11.11, fixed):
POST /pushers/sethas no per-user limit on the number of distinct(app_id, pushkey)pairs a user may register. Before this fix, every notify-worthy event copied a recipient's entire pusher list (database::list_pushers_for_user) and processed it sequentially inside a singledispatch_push_deliveriesbackground task — up toserver.push.total_timeout_seconds(default 30s) per pusher.k_max_in_flight_push_deliveries(128) bounds how many tasks may run concurrently, but nothing bounded the work inside one task: an authenticated user (no special privilege required — any account can callPOST /pushers/setrepeatedly) registering, say, 500 pushers could keep a single delivery task — and, since every subsequent message to that user repeats the same cost, potentially many of the 128 in-flight slots over time — occupied for tens of minutes to hours, materially degrading push delivery for every other user on a shared server. Fixed by bounding delivery rather than registration:room_service.cpp'sk_max_pushers_per_delivery(10) caps how many of a recipient's pushers are actually contacted per event; the rest are skipped, logged atWARN(push.pushers.truncated, mirroring the existingpush.delivery.droppedlog for the task-level cap) rather than dropped silently. Registration itself (POST /pushers/set) still accepts an unbounded number of pushers — this is a delivery-side mitigation, not a registration-side limit — so a user's excess pushers beyond the tenth are simply never contacted for any given event rather than being rejected up front; a future registration-time cap remains open work (seedocs/todos/capability-gaps.md). Notification-history recording (GET /notifications) is unaffected by this cap — it happens once per(user, event)before the pusher list is even read, so a user with more than ten pushers still sees every notification in their history even though not every pusher is contacted. As of 0.12.18, these deployment budgets are configurable:server.push.max_pushers_per_deliverydefaults to 25 andserver.push.max_in_flight_deliveriesto 256. Both remain finite; increasing them increases outbound work and concurrent resource use. -
OpenID token confusion (identified and mitigated during implementation, v0.11.11):
POST /_matrix/client/v3/user/{userId}/openid/request_token(Matrix v1.19 CS API §OpenID) mints a bearer credential meant for exactly one purpose — proving identity to a third party via the federationGET /_matrix/federation/v1/openid/userinfoendpoint. The risk: reusing the existing access-token machinery carelessly (the same table, the same lookup function, the same hash-and-compare path used forAuthorization: Bearer) would mint something that also authenticates the full client-server API — handing every third-party service a user logs into via OpenID a privilege-escalation path into that user's account. This is a token-confusion vulnerability class, not a hypothetical: the two token kinds are byte-for-byte indistinguishable opaque strings, so nothing short of a structural separation prevents one being presented as the other. Mitigated by keeping OpenID tokens in a table (openid_tokens, part of the v1 schema) and a lookup path (homeserver::federation_openid_userinfo) fully disjoint fromaccess_tokensandauthenticated_user— seedocs/auth-identity.md("OpenID tokens") for the full design anddocs/database-persistence.mdfor the schema. The two directions are conformance- and unit-tested explicitly: an OpenID token presented toauthenticated_user(the gate behind every ordinaryAuthorization: Bearercheck) is rejected, and an ordinary access token presented tofederation_openid_userinfois rejected — both fail exactly as if the token had never been issued, so a probing caller cannot even learn that token-kind confusion was attempted. A secondary, smaller risk in the same feature: the redeem endpoint is spec-mandated to require no authentication at all (it must be reachable by arbitrary third parties, not just other homeservers), so it must never be routed through the federation module's X-Matrix signature-required dispatch path — doing so by accident would either wrongly reject every legitimate caller or, worse, create an incentive to weaken that path's authentication requirement generally. Mitigated by dispatching it entirely outsidefederation::handle_inbound_federation_request, in the same homeserver-router bypass used forGET /_matrix/key/v2/server. -
Open redirect and login-token exfiltration via SSO
redirectUrl(identified and mitigated during implementation, v0.12.1):GET /_matrix/client/v3/login/sso/redirect[/{idpId}](Matrix v1.19 CS API §"Client login via SSO") takes an attacker-controlledredirectUrlquery parameter directly from an unauthenticated request and, after a successful SSO round trip, sends the user's browser there carrying a freshly mintedloginToken— a credential a client can redeem for a full access token. The spec's own security-considerations section documents the exact attack: a link like.../login/sso/redirect?redirectUrl=https://evil.example.comtricks a victim into having their login token delivered to an attacker-controlled site. Mitigated by validatingredirectUrlagainst an operator-configured HTTPS prefix allowlist (server.sso.redirect_url_allowlist,config::SsoConfig) before ever building a redirect — both at the initial/login/sso/redirectrequest (homeserver::sso_redirect_target) and again athomeserver::complete_sso_login, since the latter is called from a separate integration boundary (the operator's external SSO adapter) and must not trust a redirect target handed to it a second time without re-checking. An empty or non-matchingredirectUrlis rejected with400 M_INVALID_PARAMrather than falling back to any default destination. Config-parse validation additionally fails closed:server.sso.enabled = truewith an empty allowlist (or missingauthorization_url) is rejected outright at startup rather than shipping a half-configured SSO flow that advertisesm.login.ssowhile every redirect 400s. Seedocs/auth-identity.md("SSO login") for the full design and the dedicated[security]-tagged test that proves an off-allowlistredirectUrlis rejected. -
A remote server injecting E2EE identities for users it does not own. The
/keys/queryfederation proxy (0.12.10) now passes a remote server'smaster_keysandself_signing_keysto the client, and a client treats a master key it is shown as that user's cross-signing identity. A malicious server answering a query for its own user could also answer for@alice:good.example, and the client would take the injected master key and devices as Alice's. Mitigated infederation::accept_remote_key_query_response(): only users the server was asked about (all of them its own) are kept, and within those only keys that describe the user they are filed under — a device whoseuser_idanddevice_idmatch its position, a cross-signing key whoseuser_idmatches and whoseusagenames its role. The same rule had never been applied todevice_keyseither: before 0.12.10 any user ID in the response was passed through. Covered by[federation][keys][query][remote][security]unit tests and the[cross-signing]integration flow. -
Leaking who has verified whom. A user-signing signature over another user's master key records that the signer verified them. Before 0.12.10 every uploaded signature was merged into the key for every requester, and a user could also upload an entry under someone else's name. Now an upload is visible only to its uploader unless the uploader owns the key, and only the uploader's own signer entry is ever merged (ADR-0060).
-
Self-inflicted denial of service through the global runtime lock.
HomeserverRuntime::mutexserialises every client-server request and every inbound federation transaction. Any blocking outbound call made while holding it lets a single remote server — one it takes no privilege to operate, and no more effort than accepting a TCP connection and never answering — halt the entire homeserver for the length of that call's timeout. Two paths were reachable this way by an unauthenticated remote: a/keys/querynaming a user on the attacker's server, and any media reference pointing at it. Mitigated by releasing the mutex for the duration of every outbound network call (homeserver::RuntimeLockRelease) so a stalled peer costs one request rather than the process, and covered by a regression test that holds a real TLS peer open and asserts unrelated requests still complete. A third instance of the same bug (0.12.1):resolve_policy_server_hook's call totrust_safety.policy_server_urlwas fixed for inbound federation (#415) but still ran under the lock fromregister_local_user,create_room, and the media download/thumbnail policy check — an operator who enablestrust_safety.enabledinherits a policy server as a remote dependency that, if slow or unreachable, freezes registration, room creation, and media reads for every other user, not just the caller who tripped it. Closed the same way, and covered by the same style of regression test using the injectabletrust_safety_policy_serverhook to stand in for a stalled policy server (no outbound TLS pinning mechanism exists for this particular call, unlike the federation path). That regression test also surfaced a second, independent bug, and the same bug shape then reached production a further two times.create_room,leave_room, andinvite_user_by_threepideach self-lockruntime.mutexso they stay callable outside a request handler; calling one from a handler that already held the lock silently double-locked it, so a release of a single level left the mutex effectively held for the whole of the "unlocked" network call.create_roomwas fixed in 0.12.1,leave_roomin 0.12.3 (found by review, having been documented as closed when it was not), andinvite_user_by_threepidin 0.12.6 (found by a regression test written for issue #487 before any code changed). Each is reachable by an ordinary authenticated client: create a room, leave a room, or invite an email address through an identity server the operator trusts. 0.12.6 removes the class rather than the third instance.runtime.mutexis now ahomeserver::RuntimeMutex, a recursive mutex that records its owning thread, and the single release primitivehomeserver::RuntimeLockReleasedrains every level the calling thread holds and restores exactly that many on exit, including when the guarded call throws. No hand-written release of the runtime mutex remains ininclude/orsrc/, andscripts/reject-unsafe.shrejects new ones. Seedocs/http-transport.md, "One release primitive, and why it drains every level". Load/soak evidence for the remaining critical section is tracked indocs/todos/production-milestone.md, "Global runtime lock". -
Idle-connection thread holding through HTTP keep-alive parking. With HTTP/1.1 persistent connections (RFC 9112 §9.3) a client that has received its response can leave the connection open and simply never send the next request. Until the 2026-09-29 audit (HTTP-1) a parked connection held a main-pool worker thread for its whole idle window, and the parked-connection cap (
server.http.keep_alive_max_connections, default 8) equalled the pool size, so this text's earlier claim that the cap stopped "a single client [from converting] open sockets into held worker threads beyond the operator's budget" was wrong: eight sockets sending one request every idle − 1 seconds held every worker indefinitely. Now (ADR-0077) no worker ever waits on a quiet connection: the connection dispatcher holds every parked connection (and every new one before its first byte) on onepoll(2)thread and hands it to a worker only once it is readable. The idle window (server.http.keep_alive_idle_seconds, default 15 s) and the parked-connection cap still apply, but they bound open descriptors and memory, not workers. Once bytes arrive the head and body limits apply to that request as before. -
One client holding every worker (2026-09-29 audit HTTP-1 and HTTP-8, ADR-0077). Besides keep-alive parking, a single client could hold the whole main pool by opening connections and sending nothing (5 s each), by dribbling request heads (30 s each), by dribbling a TLS ClientHello, or by trickling request bodies one byte every 4.9 s under a 5 s inter-byte gap (about 94 s per POST, and nearly an hour for an upload declaring the 50 MiB maximum, which the transport accepted before authentication). Mitigations, which compose:
- the dispatcher above, so idle and not-yet-readable connections cost no worker;
- a per-client worker share: at most
max(1, server.http.request_threads / 4)connections from one client address (IPv6 grouped byserver.http.ipv6_client_prefix_length) are held by workers at once; a readable connection over the share waits in the dispatcher, out of the poll set, until one of that client's workers is released; - a continuous minimum body rate: once 10 s have passed since the body read started, the body must have delivered at least 16 KiB/s × (elapsed − 10 s) or the server answers 408 and closes;
- a media upload is read under
security.media.max_upload_sizeonly when its request head authenticates; otherwise the server answers 401M_MISSING_TOKEN/M_UNKNOWN_TOKENbefore reading the body and closes; - HTTP-8: a connection is closed (
Connection: close) after 1 000 requests or one hour, so a kept-alive connection cannot be held indefinitely; -
server.http.request_threads(default 32, range 4..256) sizes the pool. Residual: a client can still hold its own share of workers for the head deadline (30 s) or at the minimum body rate; many distinct addresses can still share out the whole pool (the share bounds one address, not a distributed attack); and connections from aserver.trusted_proxiesaddress are exempt from the per-client share — the reverse proxy must enforce per-client fairness itself. The TLS handshake still runs on a worker, bounded by the 15 s handshake timeout and the per-client share. -
One host filling the global connection budget (0.12.13 audit item 1, ADR-0072). Admission was bounded only by the global queue depth and the global parked keep-alive cap, and the per-IP rate limiter runs only once a request is parsed, so one host could open connections just under the slow-request thresholds and lock every other client out. Both accept loops now admit a connection only while its client key (IPv6 grouped by
server.http.ipv6_client_prefix_length, default /64) holds fewer thanserver.http.max_connections_per_ip(default 256) connections, and close a refused socket before reading a byte or starting a TLS handshake. The slot is RAII and released on every path that closes the connection. Residual: addresses inserver.trusted_proxiesare exempt, so behind a reverse proxy the per-client connection limit is the proxy's job; and many distinct hosts can still share out the global budget — the cap bounds one host's share of connections, not a distributed flood. It never bounded one host's share of worker threads: the cap (64) was eight times the pool. That is the per-client worker share of ADR-0077 (see the bullet above). -
Unauthenticated audit flood (2026-09-29 audit AUTH-1, ADR-0080). A request with an unknown bearer token, one the rate limiter refused, or one refused before routing each wrote a durable
audit_logrow (one synchronous commit on SQLite) under the runtime mutex and grew two uncapped in-memory containers, so a client with no credential chose the server's write rate and memory growth. Mitigation:access_token.rejected,rate_limit.exceededandrequest.rejectedpass throughobservability::AuditRateGate— at most 10 durable rows per kind per 60 seconds, further events counted and reported assuppressed=<n>on the kind's next row;LocalDatabase::audit_eventsandPersistentStore::audit_logkeep only the newest 1 024 rows; actor, target and reason are cut to 255 bytes on a UTF-8 boundary with invalid bytes and control characters replaced.login.rejectedandlogin.throttledare gated the same way. Residual: theaudit_logtable itself has no retention, so a distributed flood of ungated kinds still grows it. Rejected requests beyond a window's allowance keep their diagnostic log line but no audit row. -
Committed statements and their parameters retained in memory (2026-09-29 audit AUTH-11).
commit_persistent_transactionappended every committedPreparedStatement, bound parameters included, to a vector nothing trimmed, so password hashes and token hashes stayed resident for the life of the process and memory grew with every write. Mitigation: the store keeps no committed statement unless a test callsenable_statement_capture, which is bounded (max_statement_capture_capacity) and drops the oldest first. -
Outbound Application Service API transaction delivery is deliberately NOT SSRF-filtered the way federation/push/identity outbound calls are (v0.12.1, routed).
appservice::AppserviceClient(src/appservice/ appservice_client.cpp) sendsPUT /_matrix/app/v1/transactions/{txnId}to an appservice'surl, resolved via.upstream().lookup_addresses()directly — bypassingCachedServerDiscovery'sdeny_ip_rangesprivate/loopback rejection — and withOutboundRequest::allow_cleartext_http = true, permitting plainhttp://. This is a deliberate, narrow departure from the pattern the identity-server and push-gateway clients use, justified by a difference in trust, not a relaxation of it: an appservice'surlcomes from a registration FILE the operator placed on disk (appservice.registration_files) — the same trust level asfederation.worker.binaryorsecurity.trust_safety.policy_server_url, neither of which goes through SSRF-safe discovery either — never from a client request, a federation peer, or any other network-reachable input. The Application Service API's own canonical registration example, and most real-world bridges, run as a plain-HTTP process on127.0.0.1, which the ordinary https-only/private-range-rejecting path would make entirely unreachable.hs_token(sent asAuthorization: Bearer) is never placed in the URL or logged. If this trust boundary is ever weakened — e.g. registration files become editable by a lower-privilege process, or a future feature lets a namespace owner influenceurl— this exemption must be revisited alongside it. -
Unbounded appservice queues, ephemeral fan-out under the lock, and appservice-chosen directory listings (conformance wave 3, ADR-0190 to ADR-0192). The outbox of a bridge that never returned grew without bound; it is now capped per appservice (
appservice.max_queued_events), with the refusal counted, logged and audited. Ephemeral data for appservices is in memory, bounded, coalesced, and its per-update namespace matching is budgeted. The appservice ping runs under the client-outbound budget (ADR-0079) withruntime.mutexreleased, and only the appservice named in the path may ping it. An appservice may list a room in a network directory only if it declares the protocol and has an interest in the room, so it cannot publish an arbitrary private room's metadata. The client-server routes that forward anid_access_tokenrefuse one an identity-server header could not carry (identity::id_access_token_is_valid,400 M_INVALID_PARAM), in addition to the outbound client's header check (ADR-0180). -
Power-level escalation from moderator to room owner (0.12.4): authorization rule 9 was implemented only for
content.users. The scalar keys (users_default,events_default,state_default,ban,redact,kick,invite) and theevents/notificationsmaps were unbounded, so any sender meeting the oldstate_defaultcould setusers_defaultabove their own level and take the room, or setban/kickto zero and hand those powers to everyone. Separately,extract_power_level_keyfell back tocontent.eventswhen a top-level scalar key was absent, letting the same values be smuggled in under an event-type name. Fixed by implementing rules 9.1-9.3 and 9.5-9.7 in full and removing thecontent.eventsfallback. -
Room-ID squatting across homeserver domains (0.12.4):
m.room.createauthorization rule 1 was unimplemented in every sub-rule, so a federating server could mint a room whose ID claimed another homeserver's domain, and a create event could carryprev_eventsor an unrecognisedroom_version. Fixed by enforcing rule 1 per room version, including v12's inverted requirement that a create event carry noroom_idat all. -
Unauthorized local events through a fail-open authorization guard (0.12.4): the client-originated event path wrapped its authorization call in three nested guards with no else branch, so a parse failure, an unrecognised room version, or a room whose
m.room.createwas absent from local state composed the event with no authorization at all. The denial existed as rule 2 but was never reached. Fixed by making every path out of that block fail closed. -
State written under another user's identity (0.12.4): authorization rule 8 was absent, so any member holding
state_defaultcould write a state event whosestate_keywas another user's MXID — which clients routinely read as authored by that user. Fixed by enforcing the rule for every state event type other thanm.room.member, which returns from its own branch first. -
Unauthenticated CPU exhaustion via wide JSON objects (0.12.4): duplicate-key detection was O(n^2) in both the canonical-JSON parser and serializer, bounded only by the 1 MiB body cap — roughly 1.1e10 string comparisons for a body of ~150,000 unique keys, reachable on
/loginand/register. Fixed with hash-set membership checks plus an explicit object member-count cap. -
Distributed password guessing against a single account (0.12.4):
/loginwas throttled per source IP only; the limiter's per-user tier keys on the authenticated user, who pre-login is nobody. Guesses spread across many source IPs accumulated nowhere. Fixed by counting failures against the claimed user ID — existent or not, so the throttle cannot probe which accounts are real. Revised (2026-09-29 audit AUTH-2, AUTH-10): failures are counted per (account, client source) with a higher per-account ceiling, so a stranger can lock an account out only from their own address, or from everywhere only by making 50 failed logins in the window; user-interactive-auth password checks use their own per-(account, device) counter that login failures never touch; and the counters are bounded (100 000 entries, fixed-size keys, time-ordered expiry). Seedocs/auth-identity.md"Failed-login throttle". -
Root secret in swappable memory, silently (0.12.4): the master key file was read twice per authenticated request, each read
mlock-ing a fresh 4 KiB buffer. Under concurrency this could exhaustRLIMIT_MEMLOCK, after whichSecretBufferfell back to unpinned memory for the key every derived key comes from — and nothing consultedis_locked(), so nothing said so. Fixed by deriving the token HMAC keys once and caching them against the file's identity, and by warning once per process whenmlockfails. -
Stored XSS through served media (0.12.4): client-facing download and thumbnail responses set no
Content-Security-Policy, noCross-Origin-Resource-Policyand noContent-Disposition, leaving a browser fetching media directly with no layered defence against rendering it inline. Fixed by setting all three, withinlinereserved for the spec's allowlisted content types. -
Unbounded sliding-sync connection state (0.12.4):
sliding_sync_connectionsis keyed on a client-chosenconn_idand was erased only on theM_UNKNOWN_POSpath, so any authenticated client could grow it until the process was OOM-killed. A one-hour idle eviction was documented on thelast_usedfield and never implemented — the field was written on every request and read nowhere. Fixed with that eviction plus a per-user/device cap. -
No self-service account closure (0.12.4):
POST /account/deactivatehad no handler, so a user whose credentials were compromised could not close their own account; only a reversible admin lock existed. Implemented with UIA, permanent deactivation, full token revocation, and retention of the users row so the localpart is never reissued. -
A defence-in-depth control that existed only in tests (0.12.4): the X-Matrix/TLS peer cross-check read
tls_peer_server_name, which production code never assigned — only four conformance tests did. The branch could not fire, while its comment and those passing tests asserted a control that was not running. Removed, with the reasoning recorded where it stood: inbound federation TLS is one-way, so no peer name exists to compare. -
Unauthenticated outbound amplification via remote-key resolution (0.12.4):
resolve_inbound_remotecalls the remote-key resolver beforecheck_inbound_request_signature, because verifying a signature requires the key being resolved — the ordering is inherent and cannot be reversed. Under the shippeddefault_policy=allow, an unauthenticated sender could therefore name any origin in anX-Matrixheader and make this server perform.well-known+ SRV + DNS discovery and an outboundGET /_matrix/key/v2/serveragainst a host of their choosing: load here, and a reflection vector at the named third party.CachedServerDiscoverydid not help, being keyed on the very field the attacker varies. Fixed with three budgets keyed on source IP — a per-IP resolution rate, a process-wide in-flight cap that rejects rather than queues, and a negative cache for failed resolutions — with both pre-authentication containers capped and FIFO-evicted.default_policy=denywith anallowed_serverslist already closed this independently, since the policy check runs before resolution. -
Forged-but-signed federation membership events (0.12.7):
send_join,send_leave, andsend_knockverified an inbound PDU's Ed25519 signature, content hash, and sender/origin consistency, then persisted the event and membership row after checking only that the room existed — never runningauthorize_event_against_auth_events, the same gate the ordinary/sendtransaction path already applied. Signature verification establishes who signed an event; it never establishes whether they are permitted to make the transition, so any remote server holding a valid signing key for any domain could join a user into an invite-only room, or move a membership it had no power level to move, simply by presenting a correctly signed PDU. Fixed by running the identical authorization gate in the membership acceptor before anything reaches the store. - DNS-driven substitution of a server's TLS identity via SRV delegation
(0.12.7):
ServerDiscoveryResulthad no field distinguishing "where the packets go" from "whose certificate must be presented", so every outbound URL — including/_matrix/key/v2/server, the root of trust for every signature a server makes — was built from the SRV target rather than the original hostname the spec requires the certificate to name. SRV records are unsigned DNS, so anyone able to forge a DNS response could redirect a homeserver to a host presenting a certificate for a name it legitimately controls, and be trusted as the origin server. Fixed by addingServerDiscoveryResult::tls_server_nameto carry the certificate identity separately frompinned_addresses/resolved_host, and by makingresolve_destinationtake the certificate host as a required parameter rather than deriving it. - Unauthorized room-state disclosure (0.12.7):
GET /rooms/{roomId}/state/{eventType}/{stateKey}checked only that the room existed and returned the state event's content to any authenticated user, unlike its two sibling handlers in the same file, which both perform a proper membership check. A non-member could read any named piece of current room state, including state written after they left or without ever having joined; the endpoint's 403 text was also returned for a room that did not exist, at all, giving a non-member no way to distinguish the two. Fixed so a joined member reads current state, a user who has left reads the state as it stood at their leave point, and everyone else — including a banned user, a never-joined user, and a nonexistent room — receives an identical 403. - Refresh tokens outliving logout (0.12.7): single-device
POST /logoutrevoked the access token and flipped the in-memory session but never touched the refresh token, andrefresh_local_sessionadmits any refresh row that is merely unrevoked — so the client that had just logged out, or anyone holding a stolen refresh token, could immediately mint a fresh access token.logout_all, device deletion, and password change already revoked refresh tokens; single-device logout was the one path that made logout cosmetic. Fixed by revoking the device's refresh token alongside its access token on single-device logout. - Resurrection of revoked credentials via password change (0.12.7): with
logout_devices(the default), password change revoked every token for the user and then restored the caller's own device with an unfilteredUPDATE access_tokens SET revoked = false WHERE user_id AND device_id— a statement that cannot distinguish a token it revoked microseconds earlier from one revoked days earlier by a logout, an admin action, or a previous password change, and so un-revoked all of them. A password change is precisely the action a user takes after discovering a compromise, so this handed the attacker's revoked token back. Fixed by replacing the revoke-then-restore pair withrevoke_tokens_for_user_except_device, which never touches the caller's device, and by removingrestore_tokens_for_deviceoutright so no store function can clear arevokedflag. See ADR-0052. - Worker-thread exhaustion via slow request bodies and partial TLS records
(0.12.7, two findings fixed together): request-head reads enforced both
an overall deadline and an inter-byte cap, but request-body reads
enforced neither — only a fresh 15-second poll per 4096-byte chunk, so a
client dribbling a declared
Content-Lengthcould hold a worker thread for roughly(bytes / chunk) x 15s, unbounded in practice. Separately, TLS sockets were restored to blocking mode after the handshake andTlsConnection::readcalledSSL_read_exon them directly; the HTTP layer'spoll(POLLIN)proves TCP bytes arrived, never that a complete TLS record did, so a peer sending one byte of a record made the socket readable, enteredSSL_read_ex, and blocked in the kernel indefinitely — past the poll deadline, past the head deadline, past the new body deadline, outside every timeout the server believed it was enforcing. The body deadline is inert on TLS listeners without the second fix, which is why they shipped together. Fixed by adding a total body-read deadline scaled to the declared length at a 16 KiB/s floor, and by keeping TLS sockets non-blocking for the life of the connection with one sharedpump()drivingWANT_READ/WANT_WRITEagainst a deadline for both reads and writes. See ADR-0054. The body deadline was later replaced by a continuous minimum body rate (2026-09-29 audit HTTP-1, ADR-0077): with the old deadline and a 5 s inter-byte gap, one byte every 4.9 s still held a worker for most of an hour on a large upload. - Unconfined decoder on the primary production platform (0.12.7): the
thumbnail worker exists so a libpng/libjpeg-turbo memory-safety bug is
contained rather than fatal, and OpenBSD (
pledge("stdio")) and FreeBSD (cap_enter()) grant it no filesystem, socket, or process-creation access — but on Linux it installedapply_seccomp_filter(), the general server allowlist, which permitssocket,connect,sendto,openat,unlinkat,execve, andclone. On the primary production platform the decoder was therefore effectively unconfined despite the file's own comment claiming a decoder exploit had "nothing to reach for". Reaching for the existing stricterapply_worker_seccomp_filter()would have looked like a fix while changing almost nothing: it was built for the federation worker and still permits sockets,openat, andclone. Fixed with a third, decoder-specific profile,platform::apply_decoder_seccomp_filter(), matching thepledge("stdio")boundary on all three platforms, with a regression test asserting it is strictly tighter than the worker profile on sockets,openat, andclone. See ADR-0053.
September 2026 audit closures (v0.12.9)¶
A full-surface audit produced 33 confirmed findings, all fixed in 0.12.9. The threats they represent, and the mitigations now in place:
- Account locking was defeatable through token rotation.
POST /refreshauthenticates with a refresh token and so never reached the access-token moderation gate; a locked account could mint fresh access tokens indefinitely. Mitigation:refresh_local_sessioncarries the locked-account check itself. Suspended accounts are deliberately still served — see ADR-0058. - A refresh token could resurrect a deleted device. Mitigation: refresh requires the device row to still exist in the persistent store.
- A slow-reading client could exhaust the worker pool over plain HTTP.
Accepted sockets were blocking on
send. Mitigation:SOCK_NONBLOCKataccept4, and every send path waits for writability against a deadline — extending ADR-0054's rule from TLS to plaintext. - A federation peer could flood forged PDUs without penalty. Per-PDU trust failures were zeroed at transaction completion, so the backoff and circuit breaker never fired. Mitigation: the consecutive-failure count survives any transaction in which a PDU failed a trust check; only a clean transaction resets it. Room-ACL denials are excluded, being local policy rather than peer misbehaviour. These failures follow a verified signature; the resulting backoff decays after 5 quiet minutes (see the FED-4 entry below).
- Forged X-Matrix requests locked a real peer out of federation until restart
(FED-4). The H-04 fix charged a failed signature to the claimed
origin's
consecutive_failures. The origin in anX-Matrixheader is unauthenticated until the signature verifies, so three forged packets naming a peer trippedremote_trust_policy(which runs before the signature check) for every genuine request from it, and nothing ever reset the count. Mitigation: a failed signature is never charged to the claimed origin (ADR-0081). It is counted per source address (bad_signature_per_ip_rate, 30 per 60 s; excess is answered 429M_LIMIT_EXCEEDEDfor that address only, before any signature work; capped, FIFO-evicted, pre-authentication container). The per-originconsecutive_failuresis now written only by failures that follow a verified signature (malformed or oversize transactions, forged relayed PDUs), and the resulting backoff lapses afterremote_backoff_decay_window(5 minutes of quiet) instead of persisting until restart. Residual risk: an attacker sharing a source address with a real peer (CGNAT, or a reverse proxy withtrusted_proxiesunset) throttles that address, not the named origin. - Debug logs retained full event content. The event signer logged the
canonical signing payload and the signed event JSON under field names the
redactor did not recognise, and the legacy
LOG_*macros bypassed the redaction boundary entirely. Mitigation: digests instead of payloads, and everySingleLogmethod routes throughredact_log_message. - Restricted-room state diverged across federation. V2 state resolution
omitted
authorising_user_member, so valid restricted joins failed auth. Mitigation: the auth-event map is populated from resolved state. - State resolution could diverge from conformant servers by ignoring the
auth difference.
resolve_state_v2only ever considered power events literally present in the two conflicted state groups, never the spec's full conflicted set (conflicted state set + auth difference), so a power-level change reachable only through a conflicted event'sauth_eventschain (e.g. authorising a later ban) was invisible and its effect silently dropped. Room v12 additionally needed the conflicted state subgraph and an empty starting map for the iterative auth checks — support for those was missing entirely. Mitigation (0.12.13):resolve_state_v2computes the auth difference (and, for v12, the conflicted state subgraph) via a bounded, fail-closed auth-chain walk (StateResolutionRequest::event_lookup,events::max_auth_chain_walk_events); a missing or unreachable ancestor, or an over-budget walk, resolves torejected_state_conflictrather than a partial result. Seedocs/event-engine.mdand ADR-0063. - Reverse topological power ordering could be manipulated by an event's own
content.
power_level_from_eventread anm.room.power_levelscandidate's sender power from that SAME event's own new content — a self-elevating power_levels event (granting its own sender a level it does not actually hold) ranked itself by the claimed level, not its real one — and read every other candidate's power from a shared "unconflicted" state map that need not match the candidate's ownauth_eventsancestor. Mitigation (0.12.13): power is now read from them.room.power_levels(and, for v12,m.room.create) event in the CANDIDATE'S OWNauth_events(find_auth_ancestor_context, reusingevents::effective_sender_powerso the ordering agrees with the auth rules on every default), fetched through the same fail-closedAuthChainEventSourceand cap as the auth difference; an unresolvableauth_eventsentry fails the sort closed (ADR-0063) rather than guessing a default.mainline_orderwas audited against the same defect class and found not to have it. Seedocs/event-engine.md. - State resolution could be ordered by the wrong power level. The resolver read power levels integer-only while the auth path parsed room-v1-9 string values, letting a lower-power sender's event win. Mitigation: the resolver honours the room version policy, matching the auth path.
- The rate limiter was itself HashDoS-able. Bucket tables keyed by
attacker-influenceable strings used
std::hash. Mitigation: keyed BLAKE2b under a per-process random key. - Recorded TLS traffic was retrospectively decryptable. The cipher list permitted plain-RSA key exchange. Mitigation: ephemeral AEAD suites only.
- Rotating a registration token had no effect until restart. Mitigation: the Argon2id hash is cached against the token file's version, not its path.
- Concurrent server starts could strand the schema. Mitigation: a
pg_advisory_lockacross the PostgreSQL plan, and ledger-checked idempotence inside each SQLite step — see ADR-0059. - Binary media could be silently corrupted on PostgreSQL. Parameters were
sent as null-terminated C strings, truncating at an embedded NUL. Mitigation:
binary parameters are bound in libpq's binary format, and every
bytearesult column is decoded on read. - A PostgreSQL server could not read back its own signing key after a
restart (0.12.14 audit, DB-1). Only
media_blobs.byteswas decoded on read, soserver_signing_keys.secret_keycame back asbyteahex text, the key failed to load, and the server could not sign anything until the row was repaired by hand. Mitigation: the decode is generic over everybytearesult column (PQftype), and a value that cannot be decoded fails the query rather than reading as an empty secret. - Smaller closures: an exported federation verifier that honoured a caller-set
"already verified" bool; a v1 invite defaulting to room version 12 for
signature and hash; a well-known discovery overload that resolved every host
to a documentation IP, bypassing DNS and the SSRF checks; an unclamped
backfill
limit; Ed25519 secret material reaching libsodium unvalidated; guessable shared UIAA session ids; device IDs containing the key-ID separator; and transport-layer errors reaching browsers as opaque CORS failures.
Federation reads not limited to servers in the room (v0.12.15, audit FED-2)¶
Threat: any federating server that knew a room ID could call GET /state/{roomId},
/state_ids, /event/{eventId}, /backfill or POST /get_missing_events and receive the
room's state, member list and history, including private and encrypted rooms (ciphertext
and metadata). The providers took no origin and the server ACL was the only gate.
/state and /state_ids also fell back to the current state for an unknown event_id, and
get_missing_events ignored earliest_events and latest_events and returned every event
of the room above min_depth, scanned in full under the runtime lock.
Mitigation:
- Every room-scoped read now takes the X-Matrix-authenticated origin and answers
403 M_FORBIDDEN with no room data unless the origin has a joined user in the room's
current state or the room's current m.room.history_visibility is world_readable
(federation::origin_may_read_room, src/federation/event_query.cpp). The gate runs
where the data is served: the worker room snapshot for state, state_ids, backfill
and get_missing_events, and main for /event (the worker relays the origin over IPC
and main resolves the event's own room). An unknown room answers 403 so its existence is
not disclosed; an unknown event answers 404, a known event in a room the origin cannot
read answers 403. /event additionally applies the room's server ACL.
- /state and /state_ids answer 404 for an event_id that is unknown or not in the room.
- get_missing_events is the spec's breadth-first walk of prev_events from
latest_events, skipping earliest_events, min_depth and other rooms, capped at 20
events (default 10), at most 100 latest_events and 512 lookups.
Residual: the event store has no index, so each lookup is linear in the events held by the
process; the gate and the walk are bounded in the number of lookups but not O(1) each. The
worker's snapshot is refreshed per room by room_sync, so a membership change is seen with
that delay (a newly joined server may get 403 briefly; a server that just left may read
until the refresh).
PDU ingestion resolved state diverging by delivery order (v0.12.13, ADR-0064 phase B1)¶
- Room state could diverge from the rest of the federation depending on
the order PDUs happened to arrive in, and a peer that controls delivery
order could choose the outcome.
ingest_pdu_eventauthorised each inbound PDU against current state only and wrote its state straight intocurrent_state;resolve_state_v2(see above) was correct but never ran on this path, so two concurrent, individually valid state events resolved as whichever committed last — a same-room-stripe race, not a spec resolution. Mitigation (phase B1):current_stateis now a cache ofresolve_state_v2run over the room's forward extremities, recomputed after every accepted event (merovingian::homeserver::state_bookkeeping, seedocs/event-engine.mdanddocs/database-persistence.md). Aprev_eventwhose state we do not have recorded fails the PDU closed (missing_prev_state) rather than guessing from current state, which would have reintroduced the same order-dependence through a different door. - Phase B1 completion (same branch, follow-up commits): every local and
inbound-accepted event path, not just
ingest_pdu_event. A locally created event previously got no after-state group or forward-extremity update at all — a real federation regression versus pre-ADR-0064 behavior: the next inbound PDU naming it as aprev_eventfailed closed withmissing_prev_stateand was dropped, and every inbound event in a room joined after upgrading was lost the same way, because the join event itself had no state group. Mitigation:homeserver::store_local_event(the choke pointpersist_composed_event/send_eventnow goes through, guarded by a source-tree test —tests/unit/test_store_event_choke_point.cpp— that fails the build if a new call site bypasses it), the same treatment forlocal_http_router.cpp'smembership_acceptor(inbound send_join/send_leave/send_knock acceptance), and federated-join snapshot seeding injoin_room. Seedocs/database-persistence.md, "Phase B1 of spec-conformant PDU ingestion", for the full call-site list. - Residual scope, unchanged by phase B1: the receipt-order checks
themselves (auth against the state before the event vs. against current
state, soft-failure) are unaffected —
ingest_pdu_eventstill authorises only against current state, exactly as before; that is phase B2.
PDU ban-evasion accepted into client timelines (v0.12.13, ADR-0064 phase B2)¶
- A banned (or otherwise power-restricted) user could keep messages
reaching client timelines by having their server send events referencing
DAG history from before the ban, and there was no way to tell this from a
legitimately delayed event. Before phase B2,
ingest_pdu_eventauthorised every inbound PDU against current state only; a PDU whoseprev_eventspointed at a pre-ban tip passed that single check exactly the same as a normal event and was accepted outright — same effect as if the ban had never happened, for any message the banned user's server chose to backdate this way. The spec is explicit that such an event cannot simply be rejected (indistinguishable from a legitimately delayed one) but also must not reach clients. Mitigation:ingest_pdu_eventnow runs three checks in order — auth against the PDU's ownauth_events, auth against the state before the event, auth against current state — and only the last is a soft failure rather than a rejection. A ban-evading PDU passes the first two (the state it references genuinely predates the ban) but fails the third (the room's actual resolved state has the sender banned), so it is stored — participating in state resolution, so a later event cannot use it to escape detection a second time — but never becomes a forward extremity and is excluded from every client-facing timeline (/sync, sliding sync,/messages,/context,/event/{eventId}, search — seedocs/event-engine.md, "Phase B2"). Seetests/unit/test_pdu_ingestion_auth_checks.cpp, "Ban evasion" scenario. - Residual risk, unchanged by phase B2: a soft-failed state event can
still be admitted into
current_stateif state resolution later favors it — this is spec-mandated (resolution, not delivery-order tricks, decides state), and is exactly what state-res v2's power/mainline ordering exists to make deterministic and unexploitable; see "PDU ingestion resolved state diverging by delivery order" above. - Membership-acceptor path:
send_join/send_leave/send_knockacceptance runs the same three-way check in the membership acceptor (src/homeserver/local_http_router.cpp). Since 0.12.36 (R06) a content-hash mismatch there, and on/invite, is redacted and processed further rather than refused; see "What a remote server can get accepted or signed: conformance audit wave 2 (0.12.36)" below andsrc/federation/AGENTS.md.
Gaps in room history leaving inbound PDUs unapplied (v0.12.13, ADR-0064 phase C)¶
- A legitimate PDU whose
prev_eventsorauth_eventswere not yet known to this server was held asmissing_prev_stateindefinitely, even when the sending server could have supplied the missing events. Before phase C,ingest_pdu_eventfailed closed at the first unknown reference and never repaired the gap automatically. That was safe, but it partitioned the server from rooms whose history arrived out of order or via a different peer. - Mitigation: the same path now attempts a bounded backfill from the PDU's
origin. It tries
POST /_matrix/federation/v1/get_missing_events/{roomId}(up to 20 events) and thenGET /_matrix/federation/v1/event/{eventId}for individual missing references, with a hard cap of 5 outbound calls per PDU. Each returned event is verified independently — content hash, signature,auth_eventsselection, and auth against its ownauth_events— before it is stored as an outlier with a recorded after-state group. An/event/{eventId}answer must be the requested event (its computed ID must match; 0.12.13), so an origin cannot use the fetch to plant an unrelated, validly signed event. Events that fail any check are dropped; if the cap is reached or references remain missing, the original PDU still returnsmissing_prev_stateand is not applied, so unverified data never authorises a PDU. /state_idsfallback (ADR-0069, ADR-0070): when those fetches cannot close the gap, the origin is asked for the state at the missing event. Every named event is verified before use, and the snapshot is refused if it names an event from another room, arejectedevent, a non-state event or a duplicate(type, state_key). A snapshot state event with no local state-before is verified through/event_authagainst its ownauth_eventsonly.- Threat considered and closed (ADR-0070): as first implemented, such an
event was given an after-state of its own
auth_eventsplus itself. A later PDU naming it as aprev_eventwas then authorised against a thin state the origin chose, rather than the room's state before it, leaving only the current-state check and state resolution to stop, for example, a banned user's event. Such an event now carries no state group, so a PDU building on it must first obtain a verified state at it, or is held asmissing_prev_state. Test:[event_auth_outlier]intests/integration/test_pdu_ingestion_backfill_flow.cpp. - Residual risk, unchanged by phase C: the membership-acceptor path
(
send_join/send_leave/send_knock) does not backfill missing references yet; a membership PDU with a gap still fails closed asmissing_prev_state. The/get_missing_eventsresponse is trusted only after every returned event passes its own checks, but a malicious origin can still omit events it is entitled to omit; state resolution against other forks is the same residual risk every conformant server accepts. - Retained unresolved PDUs (R03, ADR-0165): a PDU still unresolved after
phase C is kept in
deferred_pdusand retried instead of being lost. A row is written only byingest_pdu_event, after the sender's signature was verified and for a room this server has current interest in, so a hostile peer cannot plant an unsigned or uninvited event; a retry re-enters ingestion and gets every check again (hash,auth_events, state before, current state), so retention confers nothing. The new surface is the table itself: a peer with a valid key can fill a room's 100 slots (1000 overall, 16 MiB of JSON) with PDUs it never lets resolve. The bounds cap the memory, expiry (24 hours or 10 retries) frees it, a full set falls back to telling the sender the PDU was not kept, and each retry is itself bounded by the backfill budgets. Residual: until those rows expire the room cannot retain further unresolved PDUs, and a legitimate sender then sees a per-PDU error for them.
Worker EDU origin substitution (audit FED-12; review/combined pending)¶
A real-signature/encrypted-IPC/SQLite regression demonstrates that a worker could substitute B's origin under A's live request and persist a fresh EDU after its parent completed. The main-side repair requires the actual receiving channel, a live non-ready/nonexpired main-owned verified parent and exact origin match, checked after any runtime-lock wait and before the existing sink. Pending locks never cover a sink callback. Admission, not full persistence, is the linearization point; already admitted work may finish after completion/stop.
Worker propagation and legitimate-fixture migration are implemented. Normal post-format origin checks pass 109 assertions / 1 case and supplemental lifecycle checks 49 / 2. Related IPC, worker, sync and proxy suites pass, including the actual socket-HTTP/separate-worker mixed PDU/EDU control (262 / 1). Independent review and fresh combined acceptance remain pending. Ordinary requests cannot authorize relays, and no unchecked compatibility handler is retained. Same-origin active context selection and signed-body content substitution remain residual risks: a parent ID is not a capability and this change does not prove signed-content membership. No whole-finding or release acceptance is claimed. See proposed ADR-0142.
Federation EDU identity, invite authorisation and device-list fan-out (audit FED-3, FED-5, FED-7)¶
m.direct_to_devicesender not bound to the sending server (FED-3). The EDU'ssenderwas stored as given, targets were not checked to be local, andmessage_idwas never read. Any peer could deliver verification requests and key requests that appeared to come from any user on any server, and invented targets grew the to-device queue without bound. Mitigation: the sink requiresserver_name(sender) == origin(otherwise the whole EDU is dropped and logged; the transaction still succeeds), queues only for existing local active users, drops an EDU whosemessage_idis missing or over 32 codepoints, de-duplicates on(origin, sender, message_id)infederation::EduIdempotenceWindow(65 536 entries, 24 hours, oldest evicted first; an ID is recorded only after the EDU's messages have committed in one transaction, so a failed store can be resent), and stops at 1 000 (user, device) deliveries per EDU. Residual risk: the window is in memory only, so a replay that straddles a restart is delivered again, and a replay older than 24 hours or evicted by 65 536 newer messages from any origin is too; persisting it would need a migration and buys little for a best-effort EDU. The origin in anedu_ingestframe remains worker-claimed, but the FED-12 repair above correlates it with a live main-owned verified request before sink admission. This is not per-EDU signature or signed-body membership verification; ADR-0071 covers PDU re-verification only.- Inbound
/inviteoverwrote a ban with a forged invite (FED-5).invite_handlerran no room authorisation rules: it rewrote the target's membership row frombantoinviteand stored the remote event as the target's currentm.room.memberstate, so local composition then allowed a banned user to join an invite-only room. Mitigation: for a room whose create event we hold, the invite is authorised against the room's current state first (sender joined and permitted to invite, target not banned or joined; 403M_FORBIDDENon failure), abanrow is never replaced on this path, and for such a room neither the event nor any state row is written (spec: the invite is delivered again by a federation transaction, whose path does the full PDU checks). The URL{eventId}must equal the event's reference-hash ID (400M_INVALID_PARAM), as the worker relay already required. An invite for a room we hold no state for keeps the stripped-state behaviour, since nothing can be checked. Residual risk: until the transaction copy arrives, a local user invited to an invite-only room we host cannot join it from this server (their join is authorised against state that lacks the invite); that is the spec's ordering, not a regression. - Device-list and signing-key EDUs wrote one row per local user, per EDU (FED-7). Each
EDU recorded a change for every local user (not only those sharing a room with the subject),
with no de-duplication or pruning, one synchronous commit per row under the runtime mutex.
Mitigation: a change is recorded only for local active users who share a joined room with
the subject; nothing is written if none does. All rows for one EDU are written as one
batch (one stream position, one transaction), and
device_list_changesholds at most one row per (observer, subject): a repeat replaces the earlier row at the newest stream position, which is what/syncneeds because it reports only the latest change per subject. The table is thereby bounded by observers x subjects that share a room, so no age-based pruning is needed and no sync token can lose a change. No migration was needed. Residual risk: the per-origin EDU rate limit is not weighted by fan-out, and the write still happens under the runtime mutex (now once per EDU rather than once per user).
Sliding sync served rooms by name; private receipts reached every member (audit CSAZ-1, CSAZ-4)¶
- Sliding sync trusted every room ID the client named. A
room_subscriptionskey was served without a membership check (name, members, all matching state and up to an unclamped number of timeline events), and thereceiptsandtypingextensions returned whatever rooms the client listed. The only secret protecting a room was its ID, and IDs below room version 12 are sequential. Each subscription also scanned the whole event store, so one request was a CPU amplifier. - Mitigation:
sync::room_access_forclassifies the caller's current membership. A subscription is served in full only forjoin;inviteyieldsinvite_stateonly (an ignored inviter's invite is omitted); every other membership omits the room. The extensions drop rooms the caller has not joined.timeline_limitis clamped to 100, and more than 256 subscriptions or 256required_statepairs on a list or subscription is a 400M_INVALID_PARAM.world_readableis deliberately not treated as permission to subscribe. Tests:[csaz-1]intests/integration/test_sliding_sync_flow.cppandtests/unit/test_sliding_sync.cpp. m.read.privateandm.fully_readwere delivered as ordinary receipts. Both/syncand sliding sync indexed every stored receipt of a room by type and user, so every member saw a user's private read position (spec: "Servers MUST NOT send them.read.privatereceipt to any other user than the one which originally sent it").m.fully_readwas stored as a receipt too, andPOST .../receipt/m.fully_read/...also federated it.- Mitigation: one shared predicate,
sync::receipt_visible_to, gates both surfaces:m.readis public,m.read.privateis visible only to its sender,m.fully_readnever appears underm.receipt, and an unknown type is withheld.m.fully_readis now stored as the owner'sm.fully_readroom account data, delivered through/syncand the sliding syncaccount_dataextension. Onlym.readis federated, and an inboundm.receiptEDU keeps onlym.readentries. Tests:[csaz-4]intests/conformance/test_receipt_conformance.cpp,tests/integration/test_receipt_federation_flow.cppandtests/integration/test_sliding_sync_flow.cpp. - Residual risk: the
receipts/typingextensionroomslists are not themselves length-capped, so a request can still name many rooms; each is one membership lookup. An invited user'sinvite_stateis the invite's stored stripped state (see audit CSAZ-7 for what that state contains).
History visibility was never enforced; state reads admitted any membership row (audit CSAZ-2, CSAZ-3)¶
m.room.history_visibilitywas read only for/publicRooms, the initialSync peek and the space hierarchy. In a room set tojoinedorinvited, a newcomer read the entire earlier history through/messages,/context,/event,/search,/relations,/threads,/syncand sliding sync; the owner's setting had no effect. Only the default,shared, behaved.initialSync,/membersand/state/{type}/{key}admitted any membership row. An invite, a knock (anyone can create one in a knock room), a declined invite, a ban and a leave all passed, and the answer was the room's current state, roster and newest messages, indefinitely: a banned or departed user kept reading./membersalso ignoredat.- Mitigation: one filter,
sync::HistoryVisibility, applies the spec's five rules to the state recorded at each event (ADR-0064 state groups; ADR-0084), with the two before-or-after special cases (m.room.history_visibilityevents and the user's ownm.room.memberevents). It is built once per request, caches per state group, and fails closed: an event with no recorded state, a broken group chain, or a rejected or soft-failed status is not visible. Every client read path that returns room events goes through it:/messages(the page skips hidden events and its token still advances),/context(target 404,events_before/events_afterfiltered,statepinned to the last returned event),/event(404),/searchand its per-result context,/relations,/threads,/synctimelines (including the initial join snapshot), the sliding sync timeline and theinitialSyncchunk. One predicate,sync::room_read_access_for, gates state reads:currentfor a joined user,as_of(the state recorded at the event that ended their join) for a user who left or was banned,nonefor a knock, an invite, a declined invite or a forgotten room. It drivesinitialSync,/members(which now honoursat, capped at a departed user's leave),/joined_members(joined only),/stateand/state/{type}/{key}. A user with no read access may peek (/messages,/event,/context,initialSync) only when the room's current visibility isworld_readable. Tests:[csaz-2]and[csaz-3]intests/conformance/test_history_visibility_conformance.cpp,tests/unit/test_sync_history_visibility.cppandtests/integration/test_sliding_sync_flow.cpp. - Residual risk: history stored before state groups existed has no recorded state, so
its visibility at the time cannot be proved: it is visible only to users who were joined when
it was sent (rule 2 from their own membership timeline, undeterminable means denied), and
sharedorworld_readablehistory from that period is not shown to later joiners or peekers until state is recorded for it. The unread counts (notification_count,highlight_count, theby_notification_countroom ordering and the push badge) count only events that pass the samesync::HistoryVisibility::can_seefilter (unread.visible(), the badge'sclassifier.visible()), and since 0.12.40 only events after the start of the user's current join (ADR-0205), so they no longer reveal how many messages preceded it. What remains is that a user who left and rejoined is not counted the events they could see before leaving./notificationsrows are returned as recorded: they were created for a user who was joined at delivery, and are not re-filtered./messagesexamines at mostserver.client_api.max_messages_events_examined(default 10,000) events per page, but each request still builds a sorted list of the room's events, so its cost grows with room history, as before. The per-request indexes (events, state groups) are O(store) to build, in line with the store's existing linear scans.
Unauthenticated proxying pinned request workers; the remote-fetch opt-in was not enforced (audit HTTP-2, OUT-7)¶
- HTTP-2:
publicRooms?server=, a remote-alias directory lookup and remote media fetches held a main-pool thread for the whole outbound round trip. The spec lets a client call them without authentication, and they ran server discovery and a federation request synchronously with a 60 sremote_timeout(a federation-worker round trip waited up to 10 s longer). Releasing the runtime mutex frees the mutex, not the thread, so a peer that accepted a connection and never answered pinned all 8 request-pool threads at about 8 requests a minute and stalled every client and inbound federation request. It also made the server send signed requests on the attacker's schedule. - OUT-7:
security.media.remote_fetch_enabled=false(the default) did not stop remote media fetches. The flag was checked only inmedia::fetch_remote_media, after discovery, the federation request (up tomax_upload_sizebytes, 120 s) and the legacy fallback had already run, and the result was then discarded. On the default configuration any unauthenticated caller could make the server connect to an arbitrary server and download up to about 50 MiB, which also made OUT-1 and OUT-2 reachable on the default configuration. - Mitigation (ADR-0079):
- OUT-7: the flag, and the
allow_remote=falsequery parameter the spec defines on the download and thumbnail routes, are checked at the top of every remote media route (legacy/_matrix/media/v3/and authenticated/_matrix/client/v1/media/, download and thumbnail, including the federation-media fallback) before any discovery or outbound call. The answer is404 M_NOT_FOUND. - HTTP-2: every client-triggered outbound call (the routes above plus remote media when
enabled) takes a slot in
http::InFlightBudgetbefore it leaves: 4 in flight overall (half the pool) and 1 per client address (rate_limit_client_key, sotrusted_proxiesapplies). Over either cap the answer is429 M_LIMIT_EXCEEDEDwithretry_after_ms1000, at once. The total deadline, discovery included, is 10 s for directory lookups and 30 s for media, and the federation-worker round trip may exceed it by at most 2 s. The slot is an RAII object taken before the runtime lock is released and dropped on every exit path. Tests:[http-2]and[out-7]intests/integration/test_client_outbound_proxy_flow.cpp,tests/unit/test_http_in_flight_budget.cpp,tests/unit/test_client_outbound_proxy.cpp. - ADR-0121: remote media no longer runs on the main pool at all. A cache miss is handed to a
dedicated media fetch pool (16 workers), admitted by its own budget (64 running or queued, 8
per client,
429over either, at once), so a peer that never answers can occupy that pool but no main-pool worker. Concurrent requests for one remote file share one fetch. Tests:[media-fetch-pool]intests/integration/test_remote_media_fetch_pool_flow.cppandtests/unit/test_remote_media_fetch.cpp. - Residual risk: an attacker holding 4 slots costs the pool 4 of 8 threads for up to 10 s
(30 s for media) at a time, and each address needs to be distinct to hold more than one, so a
botnet can keep the 4 slots busy and lock legitimate remote lookups out with 429; the other 4
threads stay available to everything that does not leave this server. Threads are still
blocked while a call is in flight: the fix bounds how many, and a separate pool or an
asynchronous proxy path was rejected for now. Outbound calls to operator-configured
destinations that an unauthenticated client can trigger (appservice
query_userandquery_room_alias, identity-serverrequestToken) are not under the budget. DNS lookups inside server discovery are bounded by the resolver, not by the deadline. Clients behind a shared address that is not a configured trusted proxy share one per-client slot.
Outbound destination agreement (OUT-1, OUT-2)¶
Malformed Matrix server authorities are rejected before discovery. Media redirects and the outbound transport share libcurl's URL parser and preserve encoded paths and queries. Userinfo, fragments, encoded authority bytes and IPv6 zone identifiers are refused. Every newly opened socket must match one approved numeric IP address and the parsed destination port. Environment proxies and connection reuse are disabled because they could bypass that request's peer check (ADR-0097). Parent real-TLS regressions cover IPv6, multiple pins and requests refused before connection. This does not replace the discovery layer's private-address policy or TLS hostname verification.
State-resolution completeness and work bounds (EVT-5, EVT-9)¶
Missing power-level ancestors no longer silently truncate mainline ordering: the shared event source loads them, and missing, malformed or cyclic ancestry rejects resolution. For room v12, the conflicted auth subgraph is built once and bounded by distinct vertices, with cycle checks and reverse reachability instead of repeated path enumeration (ADR-0095). A legal shared DAG therefore does not exhaust the budget merely by having many paths. Over-budget graphs still fail closed and require operational handling of the rejected resolution.
Redactions accepted but never applied (audit CSAZ-11)¶
Threat: content a moderator redacted stayed readable, because an m.room.redaction was relayed and never applied
(/event, /messages, /sync and federation kept serving the original), and a redaction from a sender with no
authority could be shown to clients as if it had applied (clients apply a redaction they are sent). Mitigation: a
redaction is judged by the room version's "Handling redactions" rule using the power levels among its own
auth_events (so every server decides alike), applied by overwriting the target's stored JSON with the redacted
form (the original is deleted, not kept aside), and withheld from clients, push and sync wake-ups until it applies.
One that does not apply stays withheld; one whose target is unknown waits. A redaction of the room's
m.room.create event is never applied, so a moderator cannot make the server forget the room version. Residual risk:
the verdict is made once, so a redaction that applied stays applied if the sender later loses power (as the spec has
it: events are not un-redacted), and redacting a redaction event in room versions before 11 strips its redacts, so
the target no longer shows unsigned.redacted_because after the next restart.
Moderation through the state API, oversized local events, and ungated room administration (audit CSAZ-6, CSAZ-9, CSAZ-12)¶
Threats: a ban or kick sent as PUT /state/m.room.member changed room state but not the membership projections, so
the banned user stayed listed as joined and could rejoin (CSAZ-6); a client could make this server sign an event over
the spec's size limits, which every other server rejects (CSAZ-9); any member could publish a room in the directory,
any user could take any alias on this server for a room they had joined (#admin:<server> included), a power-level-0
member could upgrade a room, creating a replacement that names the old room as its predecessor, and anyone could file
reports about any room or event as unbounded audit rows (CSAZ-12). Mitigations: every locally composed m.room.member
event that becomes current state is projected in one place (ADR-0126); compose_signed_event refuses events over
65536 signed bytes or with a type or state_key over 255 bytes (400 M_TOO_LARGE); directory visibility and alias
creation need the power to send m.room.canonical_alias (or a server administrator), aliases must be well formed and
on this server's domain, alias deletion needs the creator, that power, or an administrator (ADR-0127); /upgrade
needs the power to send m.room.tombstone before anything is created; a report needs a joined reporter and an event
in the room that the reporter can see, and a repeat report adds no row. Residual risk: a createRoom whose content fits
the limits alone but not once signed is refused after the room is stored, leaving the room created with its initial
state up to the refused event; report de-duplication covers only the rows the admin listing reads
(server.client_api.max_safety_report_rows).
Security principles¶
- Fail closed.
- Bound all resources.
- Treat all external input as hostile.
- Preserve Matrix server-blind E2EE.
- Separate privileges where practical.
- Prefer simple auditable code.
Security audit follow-up (0.12.17)¶
Presence disclosure is restricted to current joined peers. Invite, departed, banned and unrelated users do not receive another user's presence. Required presence enum/type checks and a 1024 UTF-8 byte status limit precede stream allocation. Directory POST authentication precedes any remote discovery; publication persists independently of join rules. Media moderation refuses failed transactions before changing flags, bytes or audit projections. The PostgreSQL large-room query ceiling is removed; generic failed worker reload handling is still outstanding.
Configurable operational budgets (0.12.18)¶
Normal client/federation admission, cache sizes, recovery, push and application-service budgets can be tuned within validated finite ranges. Increasing them trades memory, threads and remote-call work for headroom; no user-count capacity is implied. Larger state-ID lists still use shared per-PDU total calls/deadline, and signature/hash/authorization checks remain mandatory. Requests admitted by HTTP must fit the derived IPC request budget even when the join-response cap is smaller. Both processes derive the same frame ceiling at startup.
Fixed authentication, cryptographic, parser/decoder and sandbox boundaries remain in place. Ordinary PDUs whose missing history cannot be recovered are currently neither applied nor durably retained: larger budgets reduce one failure cause but do not substitute for a persistent retry design. See operator budgets and ADR-0109.
A peer could reset a local user's unread state (0.12.30)¶
Read receipts are now durable rows (receipts table, ADR-0169) and are the only record of
where a local user has read up to. The inbound federation receipt cap
(max_inbound_receipt_entries) used to evict the oldest receipt of any user, so a peer
flooding a large room it is a member of could remove local users' receipts and mark rooms
unread for them. At the cap it now evicts only the oldest receipt of a remote user; a local
user's receipt is never evicted. Inbound data.thread_id must be a non-empty string or the
EDU is rejected. Residual risk: the membership check is still the bound on remote receipts, and
local users' receipts are bounded by rooms, members, receipt types and threads rather than by a
fixed number.
Forged restricted joins via relay or backfill; one sliding-sync request could stall the server (0.12.31)¶
Security audit run-1 (2026-10-20) found two HIGH issues, both fixed in 0.12.31.
- A restricted join's authorising-server signature was only checked on
/sendandsend_join. The auth rules see only that the signature is present. Main's re-verification of PDUs relayed by the federation worker, and backfill / missing-history ingestion, verified the sender's signature but not the authorising server's. In a restricted room the gap was latent, because those paths' auth maps never carry the authorising user's membership and so refuse every non-invited restricted join first. That separate bug is not fixed here, and fixing it before this one would have let a compromised worker, or any remote server answering a history fetch, get a join stored that no resident server authorised. In other rooms a join carrying a forged authorising signature was accepted, against rule 4.2.1. Mitigation: every path that admits a remote PDU callsfederation::verify_join_authorising_server_signatureafter the sender check and refuses the event unless it accepts. A local key-resolution overload is retryable, never a verdict (ADR-0171). Residual risk: a future ingestion path that skips the helper reopens the gap.src/federation/AGENTS.mdrule 2 states the requirement. - Sliding-sync sorting rescanned the store on every comparison.
by_recencyandby_namewere computed inside thestd::stable_sortcomparator while the runtime lock was held, and lists, sort keys andbump_event_typeswere uncapped. One authenticated request could hold the lock for minutes. Mitigation: every sort key is computed once per room before the sort, and requests over 32 lists, 3 sort keys or 64bump_event_typesper list are refused with 400M_INVALID_PARAM(ADR-0172). Residual risk:by_recencyandby_namecost one pass over the store per list, butby_notification_count(one walk per room), the joined-room enumeration and the list filters still do per-room store work under the lock, so a list's cost still grows with the user's joined-room count; the 32-list cap bounds how often a request repeats it. The audit's other runtime-lock findings (extension rooms arrays, push glob matching, account-data scans) are not addressed by this change.
What a remote server can get accepted or signed: conformance audit wave 2 (0.12.36)¶
The 2026-10-17 conformance audit's federation findings changed what this server accepts from other servers. Each change, and its bound:
- Sender signatures under every listed key ID (R05, ADR-0183). A PDU's sender signature
is checked against every
ed25519:key ID listed for the sender's server, not only the first: unknown and expired keys are skipped, every other signature must verify, and one must. A relay can list extra key IDs (thesignaturesobject is unsigned), so an event listing more than four for its sender's server is refused before any lookup, and each candidate goes through the admitted, rate-limited, failure-cached lookup. Residual risk: an event can cost up to four lookups for its sender's server where it cost one. A relay that adds an invalid signature under a published key gets the event dropped, which it could do anyway by not forwarding it; a dropped event is not recorded, so a genuine copy arriving another way is still accepted. - Hash mismatches are redacted, not refused (R06).
send_join,send_leave,send_knockand/invite(v1 and v2) now accept a validly signed event whose content hash does not match, and keep, authorise, sign and return only its redacted copy. A sender can therefore get a stripped copy of its own event stored, which it could always do by sending the redacted form; nothing it adds outside the signed, redacted form survives. - This server co-signs restricted joins (R04, ADR-0184, ADR-0185). A
send_joinnaming one of our users asjoin_authorised_via_users_serveris no longer refused for lacking our signature; the acceptor adds it, but only whendecide_restricted_joinfinds, in our own current state, that the joining user meets an allow condition and the named user is joined with invite power. Otherwise it refuses without storing or signing. The decision reads at most 32 conditions and validates one only while this server is joined to the room it names, so state kept after leaving cannot authorise anyone. Before signing, the acceptor drops every signature the joining server filed under our name. A local client cannot name an authoriser itself: every locally composed join naming a local authoriser passes the same decision. Residual risk: the decision is only as current as our view of the allowed rooms; a user who left an allowed room in a fork we have not seen can still be authorised, which the spec accepts ("the conditions are only checked/evaluated during the join process").
Request-sized work under the runtime lock (0.12.33)¶
Security audit run-1 (2026-10-20) found five MEDIUM issues of one kind: work done while
runtime.mutex is held, sized by input a client, a sender or a remote server controls. While
the lock is held no other client request or federation callback that needs runtime state can
run, so each was a way for one party to slow the whole server (CWE-407).
- Sliding-sync extension rooms. The
receiptsandtypingroomsarrays were uncapped, and each room cost a scan of every membership row. Mitigation: each array is capped atroom_subscriptionsand de-duplicated, and rooms are classified through one membership index per request (ADR-0174). - Push glob matching.
event_matchallocated per character, andcontent.bodymatching was quadratic in the body, for every local recipient of every event. Mitigation: linear matchers with no per-character allocation, checked against the old implementation (ADR-0175). Residual risk: user push rules are evaluated server-side since 0.12.40; see "Conformance audit wave 5" below. - Global account data. One user could add types without bound, growing the rows every
account-data read scans. Mitigation: a configurable per-user cap on global types,
400 M_TOO_LARGEbeyond it (ADR-0173). Residual risk: the scans are still linear in the total row count; they are bounded, not indexed. - Appservice namespace regexes. Each match compiled the regex again, for every member of
every room an event reached. Mitigation:
appservice::Namespacecompiles once when it is built. - Federation media multipart. The delimiter search was O(body × boundary) on a body and boundary a remote server chooses. Mitigation: boundaries over 70 characters are refused (RFC 2046) and the search is linear (ADR-0176).
Run-1 low findings (0.12.34)¶
Security audit run-1 (2026-10-20) found six LOW issues, all fixed in 0.12.34.
- Header injection toward identity servers. A client's
id_access_tokenbecame an outbound header unfiltered. Mitigation: the outbound client refuses invalid header names and values before any I/O (ADR-0180). - Ported server names. Splitting user IDs on the last
:addressed members onhost:portto the port, so they silently missed every event. Mitigation: first-:splitting everywhere. - Outbound queue starvation. One dead or hostile peer could fill the shared outbound queue. Mitigation: each destination holds a bounded share; refusals are counted and logged (ADR-0177). Residual risk: sixteen or more dead destinations can still fill it together.
- Remote media owner forgery. A local account named
remote-mediaescaped local media quotas. Mitigation: cache membership is recorded per record and the name is reserved (ADR-0178). - Room version divergence. A resident server could seat a local user in a room whose create
event omits
room_version, which components then judged under different rule sets. Mitigation: one accessor, and the join must match the create event (ADR-0179). - Unadmitted third-party lookups.
/thirdparty/*held request threads with no proxy slot. Mitigation: each takes an ADR-0079 slot (ADR-0181). Residual risk: a request can still take up to the sum of the appservices' timeouts.
Conformance audit wave 5 (0.12.40)¶
- Client-chosen push rules evaluated under the runtime lock. The Push Rules API (R19) lets a
client store rules the server evaluates for every event the user receives. Mitigation: a
per-user rule cap (
server.client_api.max_push_rules_per_user, default 200), per-rule bounds, re-validation of the stored rules on read, and 405 on settingm.push_rulesas account data (ADR-0204). Residual risk: evaluation is linear in rules times event size, with a factor the operator sets. - Unread counts as a content oracle. Once the user's own rules decide unread counts (R15), a
keyword rule counted over messages from before the user joined would reveal whether history the
user may not see contains a word. Mitigation: every counted event, in the unread counts and
the push badge, passes
sync::HistoryVisibility::can_see(unread.visible(),classifier.visible()), the filter the timeline uses (ADR-0205). The filter, not the join boundary, excludes an event backfilled from before the join, which is ordered after it. Counts also start at the user's current join, so they do not reveal how many messages preceded it. - Push retries as a memory or thread sink. Retrying failed notifications (R14) keeps them, with
their event content, until they are sent. Mitigation: a bounded in-memory queue (1024
notifications, 32 per recipient) that drops rather than blocks, eight retries at most, one job
thread that sends them one at a time, and no
runtime.mutexacross the network call (ADR-0206). Residual risk: a recipient's failing gateway can fill that recipient's share.
Media: header injection, asynchronous uploads and URL previews (0.12.41)¶
- Upload filename in
Content-Disposition(R17). A client-chosenfilenameis now served back in a response header. Mitigation: a filename with control characters, path separators, invalid UTF-8 or more than 255 bytes is refused at upload (400 M_INVALID_PARAM); a remote server's filename or a{fileName}path segment that fails the same check is left out; the header is written in RFC 6266 form (quoted ASCII orfilename*=utf-8'') (ADR-0211). - Asynchronous uploads.
POST /_matrix/media/v1/createlets a client create durable rows, and a download of a reserved ID may wait. Mitigation: reservations are capped per user and server-wide (429 M_LIMIT_EXCEEDED), expire, and are rate limited; waits are bounded bypending_upload_max_wait_ms, run on the media fetch pool under its admission caps, and never holdruntime.mutex; federation answers504at once (ADR-0212). - URL previews (SSRF).
preview_urlmakes the server fetch an arbitrary URL, follow its redirects and fetch the image it names: loopback services, cloud metadata (169.254.169.254), RFC 1918, CGNAT, IPv6 ULA and link-local hosts are all targets, by literal address, by a name that resolves to one, by redirect, or by DNS rebinding between check and connect. Mitigation: off by default (security.media.url_preview_enabled); http/https only, strict URL parsing; every hop resolved and refused if any address is private, loopback, link-local, CGNAT, NAT64, multicast, reserved or insecurity.federation.deny_ip_ranges(IPv4 and IPv6), then pinned to the checked addresses; redirects followed by our gate (at most 5), never by the HTTP client; one deadline (at most 30 s) and byte caps on page and image; the same502for every failure to fetch the target, so the endpoint is not an oracle (answers given before any fetch, such as404while disabled or429from admission, say nothing about the target); per-user rate limit and the media fetch pool's admission caps; failures cached. Fetched HTML is only scanned by a bounded, non-recursiveog:*/<title>extractor that runs nothing; the image goes through the upload pipeline (MIME policy, quarantine, size cap, the requester's quota) (ADR-0213, ADR-0214). Residual risk: with previews enabled the server makes requests to public hosts on users' behalf, revealing its address to them and letting a user point it at any public service within the caps;httppreviews can be altered in transit (the content is treated as hostile either way).