Skip to content

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 /versions and 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-Matrix authorization headers. A request path that is reachable from production traffic must not share test-only credential encodings. Fixed by accepting only Authorization: X-Matrix ... on handle_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:v2 digests. Fixed by always performing a password-verification step, collapsing external failures to invalid login, and issuing keyed token-hash:v3 digests while retaining v2 lookup compatibility for existing persisted rows.

  • Registration validation-session memory growth: repeated /register/*/requestToken calls 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_ids arrays to be misread. Fixed by parsing canonical JSON objects and rejecting user_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: nosniff on every response.

  • Relayed PDU signature bypass (C1): authorize_federation_pdu previously 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 via remote_key_resolver before 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 /send shortcut 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 set PR_SET_NO_NEW_PRIVS before execv(). A compromised worker could access unrelated parent sockets/files or escalate via a setuid helper. Fixed by creating pipes with O_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_sink persisted inbound PDUs without calling authorize_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_box under a domain-separated XSalsa20-Poly1305 key derived from security.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 redirectUrl allowlist matched by unbounded prefix: the allowlist check was a bare starts_with() with no origin or path boundary, so an operator entry naming a bare origin (https://client.example.com) also matched https://client.example.com.evil.test/callback. An attacker who registered such a domain received a freshly minted m.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 share media::content_type_is_inline_safe() and fail closed to attachment.

  • Master key usable from swappable memory: load_master_key_material() tolerated a failed sodium_mlock() and returned the root secret in ordinary pageable memory after a one-time warning, so RLIMIT_MEMLOCK exhaustion 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 — raise RLIMIT_MEMLOCK for the service, or grant CAP_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 with crypto_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-worker child process that — before reading any input — clamps its address space, CPU time, output size, and descriptor count via setrlimit, sets PR_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/null sink. 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. See media/thumbnailer.hpp and media-repository.md.

  • Variable-length secret comparison leaking length (#8): comparing a config secret with a fixed-size function such as sodium_memcmp up to the shorter length branches on the secret's size before comparing bytes. Fixed by adding a domain-separated crypto_generichash path that produces fixed-size digests for both inputs and compares those digests with sodium_memcmp, hiding length differences.

  • Runtime hardening controls not applied in-process (#9): the startup hardening self-check documented core dump policy, no_new_privs, and capability bounding as alpha exceptions without enforcing them. On Linux the server now clamps RLIMIT_CORE to zero, sets PR_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::vector while 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 in core::SecretBuffer, which uses libsodium mlock and 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 selecting AUDIT_ARCH_X86_64 or AUDIT_ARCH_AARCH64 at compile time and returning SECCOMP_RET_KILL_PROCESS on 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,relro and -Wl,-z,now are now passed explicitly in meson.build and every packaging script, and the startup ELF probe verifies PT_GNU_RELRO and DT_BIND_NOW.

  • Relayed-PDU fail-open with no sender-domain key (#270): the prior (C1) mitigation only fail-closed when remote_key_resolver was wired but returned no key. On a receive-only/locked-down deployment where the resolver is never wired (because local_http_router.cpp gates wiring on outbound && discovery), authorize_federation_pdu fell through to accept the PDU with no cryptographic check, so a known peer could forge events attributed to another server. Fixed by returning 403 "sender domain signing key unavailable" whenever the sender-domain key is missing or mismatched, and removing the test-only two-arg overload that passed std::nullopt so 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 /logout and /logout/all, and suspended accounts get M_USER_SUSPENDED on 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/password ignored logout_devices (spec default true), so a token stolen from another device stayed valid after the victim changed their password. Fixed by reading logout_devices (default true) 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.cpp exempted 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 to users_default), and the demotion guard used > instead of spec's >=. Fixed by iterating the union of old and new users keys 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/validity compared the configured registration token as plaintext, bypassing the Argon2id hashed-token comparator already used by /register and leaving token material on the request path. Fixed by loading the hashed token via load_hashed_registration_token and verifying the candidate with registration_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_loopback was a weak string-prefix duplicate of the robust inet_pton-based federation::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 through crypto::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's 172. clause (address[4] >= '1' && address[4] <= '3') over-blocked public 172.1–172.3 and under-blocked the rest of 172.16/12. Fixed by removing the clause; the 172.16/12 range is handled correctly by the inet_pton numeric 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_at field to PersistentAccessToken, PersistentRefreshToken, and LocalSession, set at issuance from configurable security.access_token_lifetime_ms (default 1h) and security.refresh_token_lifetime_ms (default 30d); find_session and the refresh-token lookup reject expired tokens (audit reason token expired), forcing re-login/refresh. The advertised expires_in_ms now reads from the configured access-token lifetime so advertised == enforced. Regressed across restarts until 0.12.13: hydrate_local_database rebuilt each session without its expires_at, and find_session reads 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.

  • SecretBuffer wipe was elidable and moves left residue (#276): the destructor used std::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 by sodium_mlock-ing on construction and sodium_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-worker child. Mitigations: (a) the channel uses a master-key-authenticated crypto_kx key 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 then crypto_secretstream_xchacha20poly1305 AEAD-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 peer access_token and Authorization/X-Matrix headers are stripped and never cross IPC (#323), so a compromised worker cannot harvest or replay peer homeserver credentials; (c) the channel uses an AF_UNIX socket pair inherited at spawn with SOCK_CLOEXEC and 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 outbound Authorization header 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_response IPC 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: the sign_request frame, IpcEd25519Provider and RuntimeStartOptions::signing_override are removed, the worker's runtime installs crypto::RefusingEd25519Provider (every sign and verify request fails closed), and main answers any sign_request frame with an error and no signature without taking runtime.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/server is 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 send sign_request frames, 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 decrypts server_signing_keys.secret_key at rest) and the access-token HMAC keys (crypto::derive_token_hmac_key[_v3]), even though no worker code path actually reaches the client-server or GET /_matrix/key/v2/server handlers that use them today (FederationProxy::handle always serves /_matrix/key/v2/server and /_matrix/federation/v1/openid/userinfo on main, and the worker starts with signing disabled, so ensure_runtime_server_signing_key is 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_connect derives the IPC auth key once (in WorkerPool'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_key reads exactly that many bytes, requires EOF, and federation_worker::clear_master_key_file empties security.secrets.master_key_file in the worker's own in-memory config copy before homeserver::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_sigaction and tgkill are allowed) and run with the main process's filter (execve, open, connect allowed) 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 with SECCOMP_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, setrlimit and prlimit64 with no argument checks, and the worker runs under main's uid, so kill(getppid(), SIGSTOP) froze main without a crash (no Restart=on-failure) and prlimit64 could lower main's limits. Those syscalls are gone from the worker filter, tgkill is 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() and socketpair() were unrestricted, exposing kernel interfaces such as AF_ALG and AF_PACKET. The worker filter now admits AF_UNIX, AF_INET and AF_INET6, answers AF_NETLINK with EAFNOSUPPORT (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 govern connect() to a pathname AF_UNIX socket, so the worker can reach any local socket its uid may open; a PostgreSQL role using peer authentication 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 in main().

  • 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-worker processes using fnv1a_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 no fallback_in_process option; 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 in FederationProxy. The WorkerSupervisor restarts crashed workers automatically with exponential back-off.

  • Unbounded client-supplied via list drove unbounded thread spawning and unbounded join latency (v0.10.11): POST /join's via/server_name query 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 via std::launch::async — only throttled to run by join_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: with join_parallelism concurrent slots and up to join_timeout per 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 (default 20) truncates the ordered candidate list to the first N entries before any std::async task is spawned, capping upfront thread creation regardless of via list size; security.federation.join_race_deadline (default 45s) bounds the entire race's wall-clock time independent of the per-candidate join_timeout, so join_room always returns a definitive response within a bounded window. Candidates still in flight when either bound is hit are parked in the existing orphan_futures_ background-drain queue, unchanged from the losing-candidate path.

  • send_join state and auth_chain events were persisted with no signature verification (v0.10.11): join_room's ingestion of a send_join response's state and auth_chain arrays 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 of src/federation/AGENTS.md rule 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 by filter_verified_send_join_events: distinct (sender_domain, key_id) pairs across both arrays are resolved via the existing remote_key_resolver/key-cache infrastructure (bounded concurrency, security.federation.join_state_key_parallelism, default 100), and each event's signature is verified against its resolved key before being handed to ingest_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_join response 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's room_id with the room being joined. ingest_send_join_state stored each event under its own room_id and wrote it to that room's current_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 its send_join response carry an unsigned m.room.power_levels for 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_room drops every state and auth_chain entry that does not belong to the joined room before any key resolution, on both the synchronous and the background path (for v12, the m.room.create event belongs only when its derived room ID is the joined room), and ingest_send_join_state repeats 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), and store_server_signing_key refuses a secretless write that would change the public key of a secret-holding row. Auth-chain events are stored as outliers with no current_state row, and the start-up repair_missing_state_entries pass only promotes accepted events, 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 state array before returning join_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_events separates those from every other member's m.room.member event; 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 existing orphan_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 /sync only 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 /members endpoint, and the LocalRoom.members cache may not list every member until the background task completes (room.join.background_state_complete logs 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 set scanner_clean=true on every response fetched from a remote origin server, bypassing the AV-scanner gate for attacker-controlled federated media whenever security.media.remote_fetch_enabled is on. Fixed by reporting scanner_clean=false (there is no real scanner verdict for remote content, same as local uploads today — see media-repository.md) and introducing MediaAcceptancePolicy (allow / allow-after-scan / quarantine / deny), configured independently for local uploads (security.media.local_upload_policy, default allow-after-scan, preserving prior behaviour) and remote fetches (security.media.remote_fetch_media_policy, default quarantine, since federated content has no accountable local uploader and no real scan ever occurs). decoder_marked_safe is deliberately left true for remote content: unsafe_decoders_disabled has 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 by remote_media_download_url() building the correct two-segment path with both origin_server and media_id passed through core::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-For value 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 new federation::ip_address_is_valid() (strict inet_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=x treated 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 by admin_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_events allowed 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 from ban to leave, then knock or rejoin under normal join rules. Fixed by requiring the sender's current membership be invite, join, or knock before a self-leave is authorized, per the room v10-v12 authorization rules.
  • Unrecognized membership value silently treated as leave (2026-07 audit): parse_membership_state fell through to MembershipState::leave for 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 malformed membership value could be admitted into room state under the guise of a "leave". Fixed by rejecting an unrecognized membership value on the event under authorization, while internal lookups of already-accepted prior state still fall back to leave (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" — but authorize_federation_pdu never checked the returned key's valid_until_ts before 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 whose valid_until_ts has passed as of the request's now_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 own origin_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_ts is 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.cpp built the internal upload pipe body by copying the client-declared Content-Type into both the "declared" and "sniffed" MIME fields, so evaluate_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 as image/png. Fixed by adding media::sniff_mime_type() (magic-byte detection plus a printable-ASCII heuristic for text/plain) and sniffing the real bytes for both local uploads (client_server.cpp) and federated media fetches (repository.cpp's fetch_remote_media).
  • Unsanitized Content-Type header allowed field injection into the internal media pipe protocol (2026-07 audit): the internal declared_mime|sniffed_mime|scanner_clean|bytes format relies on | as a field delimiter, but http::header_value_is_valid() permits | in header values. A client-controlled Content-Type containing | could shift the parsed field boundaries and forge the scanner_clean flag and leading body bytes seen downstream. Fixed by rejecting a media upload whose Content-Type contains | with 400 M_BAD_REQUEST before the pipe body is constructed.
  • Registration token leaked into structured logs via an unredacted token query parameter (2026-07 audit): contains_sensitive_marker recognized access_token/refresh_token/session_token but never the bare key token, and GET /_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 via sanitized_http_target, so the raw secret reached structured logs in cleartext. Fixed by adding token to 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 and audit.append debug 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.
  • /refresh gated behind an access token it does not need (2026-07 audit): client_auth_endpoint_requires_access_token excluded only login and register_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 excluding refresh_token from 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 of accept4(..., SOCK_CLOEXEC), unlike every other fd-creation site in the codebase. Because federation workers and the thumbnail worker are spawned via posix_spawn/fork() while client connections (including long-poll /sync) remain open, an accepted socket without FD_CLOEXEC was inherited by every subsequently spawned worker for as long as the connection stayed open. Fixed by using accept4(..., SOCK_CLOEXEC) in both accept loops.
  • Canonical JSON serializer had no float guard on the signing/hashing path (2026-07 audit): serialize_canonical() used std::to_string(double) for float formatting — fixed to 6 fractional digits, not shortest round-trip — so a small magnitude like 1e-7 silently corrupted to "0.0". Unreachable from event signing today because every signing caller parses with parse_lossless(), which rejects floats at the parse boundary, but the serializer itself had no independent guard. Fixed by splitting into serialize_canonical() (floats still permitted, now via a portable shortest-round-tripping conversion, for ordinary never-signed responses like m.tag order) and serialize_canonical_strict() (rejects any float with CanonicalJsonError::float_not_allowed), with event_signer.cpp, event_id.cpp, and signable.cpp switched 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 plain std::vector and an unwiped stack buffer, neither zeroised nor mlocked, 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 bare std::array with no destructor. The original fix read roots into core::SecretBuffer and 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 exhaust RLIMIT_MEMLOCK under 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 single src/crypto/ implementation.

  • Outbound identity-server store-invite SSRF and trust-bypass surface (v0.11.9): inviting a user by a 3PID requires the homeserver to call a remote Identity Service API store-invite endpoint, introducing a new outbound attack surface: a client-controlled id_server could 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 through federation::CachedServerDiscovery (ServerDiscoveryNetwork::lookup_addresses) with the resolved addresses pinned and deny_ip_ranges adding 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 — the id_server must be listed in server.identity_server.trusted_servers or the invite fails closed with 403, 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 bearer id_access_token, not an X-Matrix federation 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 returns 502 to 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 outside runtime.mutex per the codebase convention, so a slow or hostile IS cannot block other runtime work.

  • 3PID ownership claimed without proof (AUTH-5): before AUTH-5 requestToken without 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 refused M_THREEPID_IN_USE. Mitigation: ownership is proven only by a trusted identity server. Sessions start unvalidated and /account/3pid/add, /bind and /account/3pid ask the identity server (getValidated3pid) whether the same medium and address were validated; without a trusted identity server requestToken is refused M_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/requestToken and stored client_secret/sid (v0.11.10): delegating bind, unbind, and requestToken to a remote IS extends the v0.11.9 outbound surface, and unbind auth mode 2 requires the HS to persist the IS-issued client_secret + sid so 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 reads account_threepids gains the client_secret + sid for every IS-bound 3PID and can call the IS /3pid/unbind directly, 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 (the merovingian_runtime role 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 stored client_secret/sid, making a retry impossible while the IS still holds the binding. Mitigation: unbind fails closed with 502 on transport error and on unrecognised non-2xx IS responses, so the local record — and its client_secret/sid — survives until the IS confirms or the user retries. (3) Trust-set change stranding — an operator removing an IS from trusted_servers must not strand a 3PID the user can no longer unbind. Mitigation: when the stored id_server is no longer trusted, unbind reports id_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 via CachedServerDiscovery with deny_ip_ranges, gate on trusted_servers, authenticate with a bearer id_access_token (except the unauthenticated mode-2 unbind body), fail closed with 502/403, and release runtime.mutex for the network call. (v0.12.9) client_secret and sid are 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's pushkey. Threats and mitigations: (1) open relay — an account naming a stranger's address. POST /pushers/set accepts 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 explicit require_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 has server.email cleared; 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 notify SSRF surface (v0.11.11, routed): a pusher's gateway URL (data.url on POST /_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 through federation::CachedServerDiscovery (ServerDiscoveryNetwork::lookup_addresses), which applies the operator's deny_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::OutboundClient binds the connection to the pinned address via CURLOPT_RESOLVE. (2) URL shape gate — the gateway URL is independently re-validated as https:// with a path of exactly /_matrix/push/v1/notify before any resolution is attempted (mirroring the registration-time check in client_server.cpp's matrix_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() checks config::PushConfig::enabled before doing anything else, and room_service.cpp's build_pending_push_deliveries() re-checks the same flag before reading a single pusher row or building a gateway-bound PendingPushDelivery (rule evaluation and GET /_matrix/client/v3/notifications history 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, PushGatewayClient holds no lock of its own, and the routing that calls it (room_service.cpp's run_pending_push_deliveries) never holds runtime.mutex while it runs: gateway calls happen entirely on a detached std::async task parked in HomeserverRuntime::orphan_futures_ (the same mechanism join_room's background member-fill task uses — see architecture.md), so a slow or unreachable gateway cannot block or fail the client-server request that triggered the notification. (5) Rejection handling stays narrow — a rejected pushkey 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.mutex is re-acquired only for the duration of that single delete_pusher call, 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/notifications needs history to serve, so build_pending_push_deliveries() records one PersistentNotification row (database::store_notification) per local recipient whose push rule evaluation resolves notify: true — deliberately unconditional on push.enabled and 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_notification prunes the oldest rows for that user_id beyond server.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 to orphan_futures_ below; (2) ignore-list suppression applies first — a notification is recorded only after the same trust_safety::is_delivery_suppressed check the gateway path uses, so an event from a sender the recipient has ignored is invisible to GET /notifications exactly 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_deliveries parked one std::async future in HomeserverRuntime::orphan_futures_ per qualifying event and never reaped a completed one, unlike join_room's make_join race, which already reaps before parking (see architecture.md). With push.enabled = true, this was an unbounded memory leak proportional to message volume (one orphan_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 from orphan_futures_ (via a non-blocking wait_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_ (a std::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 the via-list bound above. OUT-3 additionally splits each event into independently admitted recipient batches. One recipient is capped at G > 1 ? min(8, G - 1) : 1 running batches, preserving the global bound while leaving parallel headroom when G > 1. A move-only guard releases both atomic counters on completion or unwind, and future parking storage is reserved before launch. G=1 retains 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 a rejected list 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() (/send and /state PUTs); the membership-mutating endpoints (invite/join/leave/kick/ban, and the 3PID invite) each go through their own dedicated functions in room_service.cpp and never called build_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 from persist_membership_transition — the shared helper behind invite/ban/ kick/leave/knock — and from join_room and invite_user_by_threepid directly). The one correctness subtlety: build_pending_push_deliveries() only evaluates rules for LocalRoom::members, i.e. joined members, but an invitee is by definition not yet joined (apply_runtime_membership only adds to members on "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 an extra_recipients span 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 plain std::size_t, and the background task decremented it as its final action while holding orphan_futures_mutex_. HomeserverRuntime::~HomeserverRuntime() (and the integration test helper wait_for_background_tasks()) held that same mutex for their entire drain, including the blocking future.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 a std::atomic<std::size_t>, so the background task's decrement never takes orphan_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 hold orphan_futures_mutex_ only long enough to move the parked futures out of orphan_futures_, waiting on the moved-out copies with the mutex released. This also means a runtime shutdown no longer blocks a concurrent dispatch_push_deliveries call trying to reap or park a future while the drain is in progress. Every other site that parks or reaps orphan_futures_ (the make_join race in room_service.cpp's join_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_list was 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 by merovingian::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}, and build_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 — see docs/trust-safety.md "What ignoring a user does NOT protect against" for the full boundary. A malformed or absent m.ignored_user_list fails safe to "nothing ignored" (parse_ignored_user_list never 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 the runtime.federation.pdu_sink callback — which only ever published the sync token and never called build_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 /notifications history row and no Push Gateway request for a local recipient — push notifications silently did not work for federated rooms at all. Fixed with deliver_federation_push_ notifications() (room_service.cpp), called from the pdu_sink lambda (local_http_router.cpp's wire_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.cpp calling runtime.pdu_sink synchronously) and the worker-relayed path (worker_pool.cpp's pdu_ingest IPC handler calling the same runtime_.federation.pdu_sink on main) — so wiring the call in exactly one place cannot result in a PDU being delivered twice. Reuses build_pending_push_deliveries/ dispatch_push_deliveries unchanged rather than a second delivery implementation that could drift from the local one; carries the same push.enabled gate, the same off-request-path std::async dispatch, and the same configurable push-delivery cap as the local path.

  • Unbounded pushers per recipient (v0.11.11, fixed): POST /pushers/set has 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 single dispatch_push_deliveries background task — up to server.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 call POST /pushers/set repeatedly) 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's k_max_pushers_per_delivery (10) caps how many of a recipient's pushers are actually contacted per event; the rest are skipped, logged at WARN (push.pushers.truncated, mirroring the existing push.delivery.dropped log 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 (see docs/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_delivery defaults to 25 and server.push.max_in_flight_deliveries to 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 federation GET /_matrix/federation/v1/openid/userinfo endpoint. The risk: reusing the existing access-token machinery carelessly (the same table, the same lookup function, the same hash-and-compare path used for Authorization: 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 from access_tokens and authenticated_user — see docs/auth-identity.md ("OpenID tokens") for the full design and docs/database-persistence.md for the schema. The two directions are conformance- and unit-tested explicitly: an OpenID token presented to authenticated_user (the gate behind every ordinary Authorization: Bearer check) is rejected, and an ordinary access token presented to federation_openid_userinfo is 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 outside federation::handle_inbound_federation_request, in the same homeserver-router bypass used for GET /_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-controlled redirectUrl query parameter directly from an unauthenticated request and, after a successful SSO round trip, sends the user's browser there carrying a freshly minted loginToken — 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.com tricks a victim into having their login token delivered to an attacker-controlled site. Mitigated by validating redirectUrl against an operator-configured HTTPS prefix allowlist (server.sso.redirect_url_allowlist, config::SsoConfig) before ever building a redirect — both at the initial /login/sso/redirect request (homeserver::sso_redirect_target) and again at homeserver::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-matching redirectUrl is rejected with 400 M_INVALID_PARAM rather than falling back to any default destination. Config-parse validation additionally fails closed: server.sso.enabled = true with an empty allowlist (or missing authorization_url) is rejected outright at startup rather than shipping a half-configured SSO flow that advertises m.login.sso while every redirect 400s. See docs/auth-identity.md ("SSO login") for the full design and the dedicated [security]-tagged test that proves an off-allowlist redirectUrl is rejected.

  • A remote server injecting E2EE identities for users it does not own. The /keys/query federation proxy (0.12.10) now passes a remote server's master_keys and self_signing_keys to 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 in federation::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 whose user_id and device_id match its position, a cross-signing key whose user_id matches and whose usage names its role. The same rule had never been applied to device_keys either: 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::mutex serialises 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/query naming 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 to trust_safety.policy_server_url was fixed for inbound federation (#415) but still ran under the lock from register_local_user, create_room, and the media download/thumbnail policy check — an operator who enables trust_safety.enabled inherits 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 injectable trust_safety_policy_server hook 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, and invite_user_by_threepid each self-lock runtime.mutex so 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_room was fixed in 0.12.1, leave_room in 0.12.3 (found by review, having been documented as closed when it was not), and invite_user_by_threepid in 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.mutex is now a homeserver::RuntimeMutex, a recursive mutex that records its owning thread, and the single release primitive homeserver::RuntimeLockRelease drains 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 in include/ or src/, and scripts/reject-unsafe.sh rejects new ones. See docs/http-transport.md, "One release primitive, and why it drains every level". Load/soak evidence for the remaining critical section is tracked in docs/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 one poll(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 by server.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_size only when its request head authenticates; otherwise the server answers 401 M_MISSING_TOKEN/M_UNKNOWN_TOKEN before 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 a server.trusted_proxies address 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 than server.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 in server.trusted_proxies are 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_log row (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.exceeded and request.rejected pass through observability::AuditRateGate — at most 10 durable rows per kind per 60 seconds, further events counted and reported as suppressed=<n> on the kind's next row; LocalDatabase::audit_events and PersistentStore::audit_log keep 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.rejected and login.throttled are gated the same way. Residual: the audit_log table 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_transaction appended every committed PreparedStatement, 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 calls enable_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) sends PUT /_matrix/app/v1/transactions/{txnId} to an appservice's url, resolved via .upstream().lookup_addresses() directly — bypassing CachedServerDiscovery's deny_ip_ranges private/loopback rejection — and with OutboundRequest::allow_cleartext_http = true, permitting plain http://. 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's url comes from a registration FILE the operator placed on disk (appservice.registration_files) — the same trust level as federation.worker.binary or security.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 on 127.0.0.1, which the ordinary https-only/private-range-rejecting path would make entirely unreachable. hs_token (sent as Authorization: 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 influence url — 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) with runtime.mutex released, 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 an id_access_token refuse 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 the events/notifications maps were unbounded, so any sender meeting the old state_default could set users_default above their own level and take the room, or set ban/kick to zero and hand those powers to everyone. Separately, extract_power_level_key fell back to content.events when 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 the content.events fallback.

  • Room-ID squatting across homeserver domains (0.12.4): m.room.create authorization 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 carry prev_events or an unrecognised room_version. Fixed by enforcing rule 1 per room version, including v12's inverted requirement that a create event carry no room_id at 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.create was 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_default could write a state event whose state_key was another user's MXID — which clients routinely read as authored by that user. Fixed by enforcing the rule for every state event type other than m.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 /login and /register. Fixed with hash-set membership checks plus an explicit object member-count cap.

  • Distributed password guessing against a single account (0.12.4): /login was 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). See docs/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 exhaust RLIMIT_MEMLOCK, after which SecretBuffer fell back to unpinned memory for the key every derived key comes from — and nothing consulted is_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 when mlock fails.

  • Stored XSS through served media (0.12.4): client-facing download and thumbnail responses set no Content-Security-Policy, no Cross-Origin-Resource-Policy and no Content-Disposition, leaving a browser fetching media directly with no layered defence against rendering it inline. Fixed by setting all three, with inline reserved for the spec's allowlisted content types.

  • Unbounded sliding-sync connection state (0.12.4): sliding_sync_connections is keyed on a client-chosen conn_id and was erased only on the M_UNKNOWN_POS path, so any authenticated client could grow it until the process was OOM-killed. A one-hour idle eviction was documented on the last_used field 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/deactivate had 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_remote calls the remote-key resolver before check_inbound_request_signature, because verifying a signature requires the key being resolved — the ordering is inherent and cannot be reversed. Under the shipped default_policy=allow, an unauthenticated sender could therefore name any origin in an X-Matrix header and make this server perform .well-known + SRV + DNS discovery and an outbound GET /_matrix/key/v2/server against a host of their choosing: load here, and a reflection vector at the named third party. CachedServerDiscovery did 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=deny with an allowed_servers list already closed this independently, since the policy check runs before resolution.

  • Forged-but-signed federation membership events (0.12.7): send_join, send_leave, and send_knock verified 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 running authorize_event_against_auth_events, the same gate the ordinary /send transaction 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): ServerDiscoveryResult had 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 adding ServerDiscoveryResult::tls_server_name to carry the certificate identity separately from pinned_addresses/resolved_host, and by making resolve_destination take 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 /logout revoked the access token and flipped the in-memory session but never touched the refresh token, and refresh_local_session admits 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 unfiltered UPDATE 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 with revoke_tokens_for_user_except_device, which never touches the caller's device, and by removing restore_tokens_for_device outright so no store function can clear a revoked flag. 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-Length could hold a worker thread for roughly (bytes / chunk) x 15s, unbounded in practice. Separately, TLS sockets were restored to blocking mode after the handshake and TlsConnection::read called SSL_read_ex on them directly; the HTTP layer's poll(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, entered SSL_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 shared pump() driving WANT_READ/WANT_WRITE against 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 installed apply_seccomp_filter(), the general server allowlist, which permits socket, connect, sendto, openat, unlinkat, execve, and clone. 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 stricter apply_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, and clone. Fixed with a third, decoder-specific profile, platform::apply_decoder_seccomp_filter(), matching the pledge("stdio") boundary on all three platforms, with a regression test asserting it is strictly tighter than the worker profile on sockets, openat, and clone. 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 /refresh authenticates 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_session carries 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_NONBLOCK at accept4, 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 an X-Matrix header is unauthenticated until the signature verifies, so three forged packets naming a peer tripped remote_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 429 M_LIMIT_EXCEEDED for that address only, before any signature work; capped, FIFO-evicted, pre-authentication container). The per-origin consecutive_failures is now written only by failures that follow a verified signature (malformed or oversize transactions, forged relayed PDUs), and the resulting backoff lapses after remote_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 with trusted_proxies unset) 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 every SingleLog method routes through redact_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_v2 only 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's auth_events chain (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_v2 computes 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 to rejected_state_conflict rather than a partial result. See docs/event-engine.md and ADR-0063.
  • Reverse topological power ordering could be manipulated by an event's own content. power_level_from_event read an m.room.power_levels candidate'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 own auth_events ancestor. Mitigation (0.12.13): power is now read from the m.room.power_levels (and, for v12, m.room.create) event in the CANDIDATE'S OWN auth_events (find_auth_ancestor_context, reusing events::effective_sender_power so the ordering agrees with the auth rules on every default), fetched through the same fail-closed AuthChainEventSource and cap as the auth difference; an unresolvable auth_events entry fails the sort closed (ADR-0063) rather than guessing a default. mainline_order was audited against the same defect class and found not to have it. See docs/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_lock across 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 bytea result 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.bytes was decoded on read, so server_signing_keys.secret_key came back as bytea hex 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 every bytea result 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_event authorised each inbound PDU against current state only and wrote its state straight into current_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_state is now a cache of resolve_state_v2 run over the room's forward extremities, recomputed after every accepted event (merovingian::homeserver::state_bookkeeping, see docs/event-engine.md and docs/database-persistence.md). A prev_event whose 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 a prev_event failed closed with missing_prev_state and 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 point persist_composed_event/send_event now 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 for local_http_router.cpp's membership_acceptor (inbound send_join/send_leave/send_knock acceptance), and federated-join snapshot seeding in join_room. See docs/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_event still 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_event authorised every inbound PDU against current state only; a PDU whose prev_events pointed 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_event now runs three checks in order — auth against the PDU's own auth_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 — see docs/event-engine.md, "Phase B2"). See tests/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_state if 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_knock acceptance 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 and src/federation/AGENTS.md.

Gaps in room history leaving inbound PDUs unapplied (v0.12.13, ADR-0064 phase C)

  • A legitimate PDU whose prev_events or auth_events were not yet known to this server was held as missing_prev_state indefinitely, even when the sending server could have supplied the missing events. Before phase C, ingest_pdu_event failed 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 then GET /_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_events selection, and auth against its own auth_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 returns missing_prev_state and is not applied, so unverified data never authorises a PDU.
  • /state_ids fallback (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, a rejected event, a non-state event or a duplicate (type, state_key). A snapshot state event with no local state-before is verified through /event_auth against its own auth_events only.
  • Threat considered and closed (ADR-0070): as first implemented, such an event was given an after-state of its own auth_events plus itself. A later PDU naming it as a prev_event was 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 as missing_prev_state. Test: [event_auth_outlier] in tests/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 as missing_prev_state. The /get_missing_events response 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_pdus and retried instead of being lost. A row is written only by ingest_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_device sender not bound to the sending server (FED-3). The EDU's sender was stored as given, targets were not checked to be local, and message_id was 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 requires server_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 whose message_id is missing or over 32 codepoints, de-duplicates on (origin, sender, message_id) in federation::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 an edu_ingest frame 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 /invite overwrote a ban with a forged invite (FED-5). invite_handler ran no room authorisation rules: it rewrote the target's membership row from ban to invite and stored the remote event as the target's current m.room.member state, 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; 403 M_FORBIDDEN on failure), a ban row 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 (400 M_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_changes holds at most one row per (observer, subject): a repeat replaces the earlier row at the newest stream position, which is what /sync needs 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_subscriptions key was served without a membership check (name, members, all matching state and up to an unclamped number of timeline events), and the receipts and typing extensions 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_for classifies the caller's current membership. A subscription is served in full only for join; invite yields invite_state only (an ignored inviter's invite is omitted); every other membership omits the room. The extensions drop rooms the caller has not joined. timeline_limit is clamped to 100, and more than 256 subscriptions or 256 required_state pairs on a list or subscription is a 400 M_INVALID_PARAM. world_readable is deliberately not treated as permission to subscribe. Tests: [csaz-1] in tests/integration/test_sliding_sync_flow.cpp and tests/unit/test_sliding_sync.cpp.
  • m.read.private and m.fully_read were delivered as ordinary receipts. Both /sync and 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 the m.read.private receipt to any other user than the one which originally sent it"). m.fully_read was stored as a receipt too, and POST .../receipt/m.fully_read/... also federated it.
  • Mitigation: one shared predicate, sync::receipt_visible_to, gates both surfaces: m.read is public, m.read.private is visible only to its sender, m.fully_read never appears under m.receipt, and an unknown type is withheld. m.fully_read is now stored as the owner's m.fully_read room account data, delivered through /sync and the sliding sync account_data extension. Only m.read is federated, and an inbound m.receipt EDU keeps only m.read entries. Tests: [csaz-4] in tests/conformance/test_receipt_conformance.cpp, tests/integration/test_receipt_federation_flow.cpp and tests/integration/test_sliding_sync_flow.cpp.
  • Residual risk: the receipts/typing extension rooms lists are not themselves length-capped, so a request can still name many rooms; each is one membership lookup. An invited user's invite_state is 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_visibility was read only for /publicRooms, the initialSync peek and the space hierarchy. In a room set to joined or invited, a newcomer read the entire earlier history through /messages, /context, /event, /search, /relations, /threads, /sync and sliding sync; the owner's setting had no effect. Only the default, shared, behaved.
  • initialSync, /members and /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. /members also ignored at.
  • 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_visibility events and the user's own m.room.member events). 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_after filtered, state pinned to the last returned event), /event (404), /search and its per-result context, /relations, /threads, /sync timelines (including the initial join snapshot), the sliding sync timeline and the initialSync chunk. One predicate, sync::room_read_access_for, gates state reads: current for a joined user, as_of (the state recorded at the event that ended their join) for a user who left or was banned, none for a knock, an invite, a declined invite or a forgotten room. It drives initialSync, /members (which now honours at, capped at a departed user's leave), /joined_members (joined only), /state and /state/{type}/{key}. A user with no read access may peek (/messages, /event, /context, initialSync) only when the room's current visibility is world_readable. Tests: [csaz-2] and [csaz-3] in tests/conformance/test_history_visibility_conformance.cpp, tests/unit/test_sync_history_visibility.cpp and tests/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 shared or world_readable history from that period is not shown to later joiners or peekers until state is recorded for it. The unread counts (notification_count, highlight_count, the by_notification_count room ordering and the push badge) count only events that pass the same sync::HistoryVisibility::can_see filter (unread.visible(), the badge's classifier.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. /notifications rows are returned as recorded: they were created for a user who was joined at delivery, and are not re-filtered. /messages examines at most server.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 s remote_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 in media::fetch_remote_media, after discovery, the federation request (up to max_upload_size bytes, 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=false query 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 is 404 M_NOT_FOUND.
  • HTTP-2: every client-triggered outbound call (the routes above plus remote media when enabled) takes a slot in http::InFlightBudget before it leaves: 4 in flight overall (half the pool) and 1 per client address (rate_limit_client_key, so trusted_proxies applies). Over either cap the answer is 429 M_LIMIT_EXCEEDED with retry_after_ms 1000, 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] in tests/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, 429 over 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] in tests/integration/test_remote_media_fetch_pool_flow.cpp and tests/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_user and query_room_alias, identity-server requestToken) 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 /send and send_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 calls federation::verify_join_authorising_server_signature after 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.md rule 2 states the requirement.
  • Sliding-sync sorting rescanned the store on every comparison. by_recency and by_name were computed inside the std::stable_sort comparator while the runtime lock was held, and lists, sort keys and bump_event_types were 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 64 bump_event_types per list are refused with 400 M_INVALID_PARAM (ADR-0172). Residual risk: by_recency and by_name cost one pass over the store per list, but by_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 (the signatures object 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_knock and /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_join naming one of our users as join_authorised_via_users_server is no longer refused for lacking our signature; the acceptor adds it, but only when decide_restricted_join finds, 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 receipts and typing rooms arrays were uncapped, and each room cost a scan of every membership row. Mitigation: each array is capped at room_subscriptions and de-duplicated, and rooms are classified through one membership index per request (ADR-0174).
  • Push glob matching. event_match allocated per character, and content.body matching 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_LARGE beyond 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::Namespace compiles 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_token became 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 on host:port to 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-media escaped 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 setting m.push_rules as 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.mutex across 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-chosen filename is 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 or filename*=utf-8'') (ADR-0211).
  • Asynchronous uploads. POST /_matrix/media/v1/create lets 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 by pending_upload_max_wait_ms, run on the media fetch pool under its admission caps, and never hold runtime.mutex; federation answers 504 at once (ADR-0212).
  • URL previews (SSRF). preview_url makes 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 in security.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 same 502 for every failure to fetch the target, so the endpoint is not an oracle (answers given before any fetch, such as 404 while disabled or 429 from 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-recursive og:*/<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; http previews can be altered in transit (the content is treated as hostile either way).