Merovingian Security Audit Report — 29 September 2026¶
Scope: Full security audit across all high-risk surfaces, audited sequentially one
area at a time.
Baseline: 6a04242 (0.12.13).
Method: For each area, one specialist auditor subagent, then one independent
adversarial verifier subagent whose job was to refute each finding, then orchestrator
spot-checks of every finding rated high or critical. Only findings that survived
verification are listed as findings; refuted ones are recorded at the end of each area.
Authority: Matrix spec v1.19 (docs/matrix-v1.19-spec/), project AGENTS.md files,
docs/security-coding-rules.md, docs/threat-model.md.
Relationship to the earlier report: docs/security-audit-report-2026-09.md
(33 findings, fixed in 0.12.9). Each area below re-checks the relevant earlier fixes for
regressions before looking for new defects.
This report records findings only. No production code was changed by this audit. Every
finding has a GIVEN/WHEN/THEN acceptance criterion; per AGENTS.md, the fix for each
must start with that test.
Attacker models¶
| ID | Attacker |
|---|---|
| A1 | Unauthenticated remote client |
| A2 | Authenticated local user (non-admin) |
| A3 | Malicious remote federating homeserver |
| A4 | Malicious or compromised application service |
| A5 | Compromised federation worker or thumbnail worker (escape towards the main process) |
| A6 | Local unprivileged OS user on the host |
| A7 | Malicious identity server, push gateway or remote media server answering our requests |
Severity guide¶
- Critical: remote auth bypass, RCE, memory corruption reachable by A1/A3, signing-key leak.
- High: spec MUST violation or access-control bypass with real impact, cheap remote DoS, secret leakage, integrity defect reachable in production.
- Medium: real defect that is partly mitigated or needs unusual preconditions.
- Low: defence in depth.
Areas¶
| # | Area | Status |
|---|---|---|
| 1 | Authentication, sessions, application-service authentication | done: 2 high, 3 medium, 7 low |
| 2 | Client-Server authorisation and room access control | done: 4 high, 4 medium, 4 low |
| 3 | HTTP transport, TLS and request-level DoS | done: 3 high, 3 medium, 2 low |
| 4 | Federation inbound (X-Matrix, PDU ingestion, keys, backfill, membership) | done: 1 critical, 5 high, 3 medium, 3 low |
| 5 | Event engine (auth rules, state resolution, redaction, canonical JSON) | done: 5 high, 4 medium, 3 low |
| 6 | Outbound requests and SSRF (discovery, push, identity, appservice, remote media) | done: 1 high, 3 medium, 4 low |
| 7 | Cryptography, key management and worker IPC | done: 1 high, 1 medium, 4 low |
| 8 | Process isolation and platform hardening (workers, seccomp, sandboxes) | done: 1 high, 2 medium, 5 low |
| 9 | Media repository and thumbnailer | done: 5 medium, 3 low |
| 10 | Database, persistence and migrations | done: 1 high, 3 medium, 6 low |
| 11 | Configuration, observability, logging and packaging | done: 5 low |
Remediation status¶
Re-verified: 5 October 2026 against 07317921 (0.12.18). Each finding was re-checked by
reading the current code and looking for a test that implements its acceptance criterion.
Nothing was built or run for that check. CHANGELOG, ADR and
docs/todos/capability-gaps.md claims were treated as claims, not evidence. The findings
below are otherwise unchanged.
Update: 6 October 2026 — every medium finding is now fixed on branch
docs/audit-2026-09-29-status (0.12.19). The first fixes for the ten that were still
open on 5 October were reviewed before being accepted, and six were wrong or incomplete.
Each correction has a regression test that failed against the first fix:
- ISO-2: the generated BPF entry for
tgkillskipped one instruction too many, so every syscall not matched earlier in the worker allow list, includingkill,prlimit64andexecve, was allowed. The test that caught it had been changed to skip. Fixing that then showed that the worker'sstart_runtime()re-applied the main process'ssetrlimit, which the corrected filter denies, so a hardened worker would have crash-looped in production. - OUT-4: the cache was consulted after the network fetch, so every request still went to the origin.
- MED-5: every port was stripped, so
example.org:8449counted as this server. - MED-6: the 10 MiB per-user default refused one upload at the 50 MiB upload limit and cut off each remote origin after 10 MiB.
- DB-5: media upload writes were still discarded.
- CSAZ-10: the caps on one-time keys, signatures and filters, and the signature index, were missing.
Update: 7 October 2026, branch feature/media-blobs-on-demand (0.12.20) — media
bytes moved out of memory into the database (ADR-0119), which changes how MED-6 and
OUT-4 are resolved (rows below); the remote media cache has its own storage budget
(ADR-0120); OPS-3 is fixed and MED-4 partly fixed.
Update: 8 October 2026, branch fix/auth-low-findings (0.12.23) — every AUTH finding
is now fixed: the low findings AUTH-2, AUTH-5, AUTH-7, AUTH-8, AUTH-10 and AUTH-12, the
field-length cap left over from AUTH-9, and the residual gaps noted under AUTH-1 and AUTH-4
(ADR-0122, ADR-0123, ADR-0124; rows below).
Update: 8 October 2026, branch fix/csaz-low-findings (0.12.25) — CSAZ-6, CSAZ-9,
CSAZ-11 and CSAZ-12 are fixed (ADR-0125, ADR-0126, ADR-0127; rows below). Two of the
audit's expectations did not match the spec and were not implemented as written: a user
banned through the state API still reads the room's history up to the ban (CSAZ-6), and a
report from a non-member answers 404, not 403 (CSAZ-12).
Update: 9 October 2026, branch fix/remaining-low-security-audit (intended 0.12.26) —
HTTP-7, EVT-10, EVT-11, OUT-6 and OUT-8 have inspected fixes and freshly rebuilt focused
regression evidence. EVT-12(a,c) are implemented and tested; the legacy fractional
power-level issue in (d) remains unresolved. Parent runs passed 1717 assertions in 15
unit cases and 1331 assertions in 4 conformance cases. The full combined suite and the
rest of the low-severity pass are not complete.
Update: 11 October 2026 — the inspected crypto batch accepts CRY-3, CRY-4, CRY-5, CRY-6 and OPS-6 with focused regression evidence. Parent rebuilt crypto/auth tests passed 777 assertions in 102 cases. ISO-7 is partial: descriptor-bound text readers are implemented, but TLS/private-key and cache identity/load association review remains. ADR-0128 records why the proposed unversioned KDF replacement is not applied. Combined-suite acceptance and the remaining pass are still pending.
Update: 11 October 2026 — ISO-5 is accepted after source review and independent
execution in the normal integration binary: 554 assertions in 4 cases, plus
13 assertions in the closed-stderr case. BSD execution and sink fault injection
were not run. The earlier full WSL run completed with 52 OK, 4 FAIL and 0 TIMEOUT;
it is not an acceptance result. Three fixture/format/diagnostic repairs and
two genuine inherited-seccomp compatibility fixes followed. Main now admits
only fchmod(0600) and umask(0077), while both hardened worker profiles deny
both syscalls. New kernel tests were observed RED then GREEN. Focused parent
runs passed 378 assertions in 20 unit cases and 606 assertions in 13 integration
cases, including real startup. ADR-0129/0130 document the bounded grants.
The whole ISO-6/DB-10 reviews remain pending.
Update: 11 October 2026 — the post-repair unified WSL CLI exited 0.
Both Meson's completed text log and all 56 JSON result records report OK;
there were zero failing targets and zero timeouts. Catch2 reports one skipped
Landlock ABI-6 unit case on this kernel. FED-9 is accepted after parent review
of the source, proxy fallback and shard selection plus independent normal-target
execution: 27 assertions in 1 [fed9] case and 167 assertions in 26 [routing]
cases. No dedicated live worker IPC regression ran. This verifies the current
checkpoint, not the remaining low findings or final release/version acceptance.
Update: 15 October 2026 — MED-7 and MED-8 are accepted, and MED-4's local thumbnail path now revalidates the source record after the unlocked decode. The fresh full WSL run completed 56 OK, 0 failed, 0 timed out; tag runs of the new integration scenarios passed MED-4 255 / 1, MED-7 132 / 2 and MED-8 201 / 3 (ADR-0143, ADR-0144, ADR-0145).
Update: 15 October 2026 — ISO-4, ISO-6, ISO-8 and DB-10 are fixed. The SQLite,
umask and package-script parts were on the branch already and were reviewed; the
--log-file sink, the worker's socket-family filter and the systemd directives are new
(ADR-0146, ADR-0147). Fresh full WSL run: 56 OK, 0 failed, 0 timed out.
Update: 15 October 2026 — OPS-5 is fixed. Every packaging path builds
--buildtype=release; the Dockerfile pins its base by digest and ships both workers and
their libraries; the packages workflow boots the installed deb and the Docker image
through scripts/check-startup-hardening-gate.sh (ADR-0148).
Update: 15 October 2026 — DB-4 is fixed. A member event's membership row and invite
change commit in the event's own transaction on both backends, and hydration reconciles
the rows from current state (ADR-0149). Fresh full WSL run: 57 OK, 0 failed, 0 timed out.
Update: 15 October 2026 — the five partly fixed lows are fixed: EVT-12 (d) (ADR-0150), ISO-7, OUT-3 (ADR-0151), MED-4 (ADR-0152) and FED-10's cross-shard aggregate (ADR-0153).
Update: 15 October 2026 — the last seven lows are fixed. DB-7 is new (pooled write connections, ADR-0154); FED-12, DB-6, DB-8, DB-9, OPS-2 and OPS-4 were implemented earlier on this branch and are accepted after their fixes were checked against each finding and their regressions re-run (ADR-0137, ADR-0138, ADR-0139 and ADR-0142 accepted).
Summary: the critical finding, all 23 high findings and all 31 medium findings are fixed. All 46 low findings are fixed.
Fixed¶
- Critical: FED-1.
- High: AUTH-1, AUTH-11, CSAZ-1, CSAZ-2, CSAZ-3, CSAZ-4, HTTP-1, HTTP-2, HTTP-5, FED-2, FED-3, FED-4, FED-5, FED-7, EVT-1, EVT-2, EVT-3, EVT-4, EVT-6, OUT-7, CRY-1, ISO-1, DB-1.
- Medium: all 31 — AUTH-3, AUTH-4, AUTH-6, CSAZ-5, CSAZ-7, CSAZ-8, CSAZ-10, HTTP-3, HTTP-4, HTTP-8, FED-6, FED-8, FED-11, EVT-5, EVT-7, EVT-8, EVT-9, OUT-1, OUT-2, OUT-4, CRY-2, ISO-2, ISO-3, MED-1, MED-2, MED-3, MED-5, MED-6, DB-2, DB-3, DB-5.
- Low: AUTH-2, AUTH-5, AUTH-7, AUTH-8, AUTH-9, AUTH-10, AUTH-12, CSAZ-6, CSAZ-9,
CSAZ-11, CSAZ-12, HTTP-6, HTTP-7, EVT-10, EVT-11, OUT-5, OUT-6, OUT-8, OPS-3,
CRY-3, CRY-4, CRY-5, CRY-6, OPS-6, ISO-5, FED-9, MED-7, MED-8, ISO-4, ISO-6, ISO-8,
DB-10, DB-4, OPS-5.
The AUTH rows are in "Fixed — low, AUTH" below. OUT-5:
CURLOPT_PROXYis set to""on the only curl handle, but there is no regression test withhttps_proxyset. OPS-3: media size limits that do not parse are rejected byvalidate(), the effective limits are logged at startup, andsrc/config/AGENTS.mdnames the accepted suffixes.
Residual notes on fixed findings:
- EVT-1: rule 4.2 (v10 numbering) applies to every
m.room.memberevent that carriesjoin_authorised_via_users_server.src/events/authorization.cppchecks it only in the restricted branch, after the "already invited or joined" allow. Federation ingestion verifies the signature for every PDU carrying the key (src/federation/inbound_request.cpp), so remote events are covered, but the auth rule is not ordered as the spec orders it and the cryptographic path has no test. Correction (21 October 2026): "every PDU" was untrue. Only/sendandsend_joinverified it; worker relay re-verification and backfill ingestion did not (security audit run-1, HIGH). Since 0.12.31 all four, and thesend_joinstate stored when this server joins, callverify_join_authorising_server_signature, with tests on each path (ADR-0171). - AUTH-1: fixed by rate-capping unauthenticated audit rows. Since 0.12.23
login.rejectedandlogin.throttledare capped too. - AUTH-4: since 0.12.23 every client-reachable Argon2id call (
/register, password change and user-interactive-auth password checks, as well as/loginand the registration-token check) takes a slot in the shared admission budget, answering429 M_LIMIT_EXCEEDEDwithout counting a failed attempt when none is free, and hashes with the runtime mutex released, re-checking the username, session and stored hash once the lock is re-taken. A one-time dummy hash on the first unknown-user login and a cached registration-token-file hash still run under the lock. - ISO-2: the worker still runs under main's uid, which the audit preferred; the seccomp argument checks and, on Landlock ABI 6 kernels, signal scoping are what stop it signalling main.
- MED-5: discovery results that resolve to this server are not rejected. A self-fetch
cannot loop (the federation media endpoint serves local media only, and the legacy
fallback carries
allow_remote=false), but it costs one outbound request. - DB-1, DB-2: the PostgreSQL integration tests skip unless
MEROVINGIAN_TEST_POSTGRESQL_URIis set.
Fixed — medium, 6 October 2026¶
| ID | Resolution |
|---|---|
| AUTH-3 | Application-service sender_localpart users are created at startup from the loaded registry (the loop read the runtime registry before it was populated) and ordinary /register rejects them with M_EXCLUSIVE. |
| CRY-2 | The reader-side dispatch queue is capped by frames (1024) and bytes (128 MiB); a worker past either has its channel marked unhealthy and is replaced. IPC sockets have a 30 s send timeout. ADR-0111. |
| CSAZ-10 | To-device queues are capped per device by count and age (migration 019, ADR-0117); rows written before migration 019 are no longer dropped on the next enqueue. One-time keys (1000 per device), key signatures (10000 per user) and filters (1000 per user) are capped, refusing with 400 M_TOO_LARGE; identical filters are deduplicated; one fallback key per algorithm per device, as the spec requires; key signatures are indexed by target. ADR-0118. |
| DB-2 | The SQLite room snapshot loads event relations with a room-scoped JOIN instead of one parameter per event. |
| DB-5 | Token revocations (logout, logout-all, refresh rotation and reuse, device deletion, password change) are checked against the store and answer 500 when not durable (ADR-0116). A media upload's record and blob commit in one transaction; on failure the upload answers 500 and is rolled back in memory. |
| FED-6 | handle_make_membership rejects a {userId} that is not on the requesting server. |
| ISO-2 | The worker filter denies kill, tkill and setrlimit, allows tgkill only on the worker's own thread group and prlimit64 only to read its own limits; Landlock ABI 6 kernels also scope signals. The worker's start_runtime() no longer re-applies the main-process profile. Tested by installing the real filter in a forked child ([iso2]) and by starting a real hardened worker ([federation-worker][seccomp]). ADR-0112. |
| MED-5 | Media serverName comparisons lowercase the name and drop only a trailing :8448. ADR-0115. |
| MED-6 | Media bytes are no longer held in memory at all; they are read from the database per request (0.12.20, ADR-0119). Local quota defaults are 250GiB total, 10GiB per user and 1000000 records, and cached remote media has its own 25GiB / 50,000-file budget instead of counting toward local quotas (ADR-0113, ADR-0120). |
| OUT-4 | Remote media admitted within the TTL is served from the stored copy before any outbound slot, discovery or request. Since 0.12.20 the cache and its copies are durable and survive restarts; a re-fetch after the TTL replaces the stored copy, eviction deletes it, and quarantined or removed copies answer 451/404 without a re-fetch (ADR-0114, ADR-0119). |
Fixed — low, AUTH, 8 October 2026¶
| ID | Resolution |
|---|---|
| AUTH-2 | Login failures are counted per (account, client source) and per account: 5 per source refuses that source, 50 per account refuses all sources, within 15 minutes (security.login_throttle.*). A correct login clears only its source. UIA password checks have their own (account, device) counter that login failures never touch. docs/auth-identity.md corrected. ADR-0122, superseding ADR-0066. |
| AUTH-5 | 3PID ownership is proven only by a trusted identity server. requestToken without one answers 400 M_THREEPID_MEDIUM_NOT_SUPPORTED; delegated sessions start unvalidated, and /account/3pid/add, /bind and POST /account/3pid confirm with the identity server (getValidated3pid) for the same medium and address before binding, otherwise 400 M_SESSION_NOT_VALIDATED. A consumed session is erased. ADR-0123. |
| AUTH-7 | Register, login and assertion refuse a user in another service's exclusive namespace: 400 M_EXCLUSIVE on /register, 403 M_EXCLUSIVE on /login and assertion. |
| AUTH-8 | Identity assertion is refused on every /account/* endpoint except GET /account/whoami (403 M_FORBIDDEN). ADR-0124. |
| AUTH-9 | The field-length cap is done: every structured log field value is capped at 2048 emitted bytes, on a character boundary, with a ...[truncated N bytes] marker. |
| AUTH-10 | The failure tables store fixed-size digests, hold at most 100,000 entries each, and expire from a time-ordered list, so no operation scans the table (auth::FailureWindowTable). |
| AUTH-12 | Asserting a user that is not registered or is deactivated answers 403 M_FORBIDDEN; /register and /login are exempt. ADR-0124. |
Fixed — low, CSAZ, 8 October 2026¶
| ID | Resolution |
|---|---|
| CSAZ-6 | Every locally composed m.room.member event that becomes current state is projected (the memberships row, LocalRoom.members, device-list share changes, invite metadata) in persist_composed_event, whichever endpoint sent it; the membership APIs no longer project on their own. A ban through PUT /state/m.room.member removes the user from /joined_members and /joined_rooms and stops them rejoining. The audit's expected test outcome was wrong: a banned user's /messages answers 200 with the events they could see before the ban and none after, as the spec's history visibility rules require ("may see any events which they were allowed to see before they left") and ADR-0084 implements. ADR-0126. |
| CSAZ-9 | compose_signed_event refuses an event whose signed canonical JSON is over 65536 bytes, or whose type or state_key is over 255 bytes, with 400 M_TOO_LARGE on /send, /state, the membership endpoints and createRoom (which refuses oversized client content before storing the room). The errcode reaches the client through OperationResult::errcode and LocalHttpResponse::errcode. |
| CSAZ-11 | Redactions are judged by the room version's "Handling redactions" rule against the power levels in the redaction's own auth_events, applied by overwriting the target's stored JSON (the original is deleted), and withheld from clients until they apply; PUT /redact is routed. The capability-gaps row is corrected. ADR-0125. |
| CSAZ-12 | (a) /report/{eventId} answers 404 M_NOT_FOUND unless the reporter is joined and the event is in the room and visible to them (the spec's 404, not a 403), and a repeat report writes no second audit row. (b) /upgrade refuses with 403 before creating anything unless the user may send m.room.tombstone. (c) directory visibility needs canonical-alias power or a server administrator. (d) alias PUT validates the grammar and this server's domain (400 M_INVALID_PARAM) and needs canonical-alias power or an administrator; aliases record their creator (migration 021); DELETE is implemented for the creator, a user with canonical-alias power, or an administrator. ADR-0127. |
Fixed — low, crypto and bootstrap, 11 October 2026¶
| ID | Resolution |
|---|---|
| CRY-3 | Master files require 32–4096 bytes and must not be all zero. Operators still need random material. Existing BLAKE2b derivations are retained to preserve persisted ciphertext/digests; changing the primitive without a new format and migration is rejected in ADR-0128. Compatibility regression covers all four historical labels and message/key orientation. |
| CRY-4 | SecretBuffer uses isolated sodium allocations, and derived-key values/copies own independently locked allocations. Kernel mapping checks cover live survivors and all derived-key copies. Both token and signing-box caches fail closed under forced locked-memory exhaustion. |
| CRY-5 | Validation-session lookup and consumption use the variable-length constant-time helper; source-boundary guard and 3PID behavior tests cover the sinks. |
| CRY-6 | New access/refresh credentials require the v4 keyed digest. Missing/insecure/unreadable roots and failed locked cached-key copies return 503 rather than downgrade or add credential rows. Legacy validation remains separate. |
| OPS-6 | Bootstrap passwords use the bounded, mode/owner/type-checked descriptor reader and locked SecretBuffer, wiped after use. The manual uses a private directory and correctly says successful bootstrap continues serving; no one-shot exit behavior is claimed. |
Fixed — low, thumbnail isolation, 11 October 2026¶
| ID | Resolution |
|---|---|
| ISO-5 | Both thumbnail exec paths receive only fixed PATH; fd 2 is duplicated onto a CLOEXEC, RAII-owned null sink opened before filesystem sandboxing and kept above stdio. Main validates the complete PNG signature, initial 13-byte IHDR framing and legal nonzero dimensions against the worker report. Invalid/mismatched output returns 502 without bytes, content type or dimensions. No image decoder or CRC/image-data validation enters main. Normal-binary parent verification and the closed-stderr case passed; BSD execution and sink fault injection remain unverified. |
Fixed — low, federation routing, 11 October 2026¶
| ID | Resolution |
|---|---|
| FED-9 | EDU-only /send bypass requires a parsed object with an explicitly empty pdus array. Nonempty malformed-first-PDU transactions retain the empty shard key, skip main's room-sync fallback and enter worker shard zero; later PDUs are not used to replace the key. Parent review traced the proxy and shard selector. Independent normal-target runs passed 27 assertions / 1 case and related routing passed 167 / 26. ADR-0131. No dedicated live worker IPC regression ran. |
Fixed — low, media, 15 October 2026¶
| ID | Resolution |
|---|---|
| MED-7 | The federation media download consults the same media-policy decision as client downloads before resolving the repository or reading a blob; a denial answers 403 with no bytes and the federation dispatcher returns its JSON error, not a multipart success. Real SQLite tests cover a persisted admin rule against a genuinely signed request, recovery after the rule is removed, and the configured unavailable-policy hook with and without fail-open. ADR-0144. |
| MED-8 | The thumbnail worker checks the crop's fill area against the request's effective pixel budget (never above the 4,096,000-pixel worker ceiling) in 64-bit arithmetic before allocating, answering too_large (413) instead of allocating the intermediate. main prepares an empty internal_error frame before reading input and returns it if anything throws. Tested against the real worker, including the audit's 64 GiB inputs. ADR-0143. |
Fixed — low, isolation and SQLite, 15 October 2026¶
| ID | Resolution |
|---|---|
| ISO-4 | The worker opens main's SQLite file SQLITE_OPEN_READONLY and refuses a WAL-mode file (a read-only connection would still write its shared-memory index); its Landlock grant on the database directory is read-only. Tested by a production worker bootstrap whose writes fail and by a Landlocked child whose raw writable open() is denied ([iso4]). The threat model's "read-write" wording is corrected. |
| ISO-6 | Every executable sets umask(0077) first and the unit sets UMask=0077; package scripts write the registration token under umask 077; SQLite creates missing directories 0700 and the file 0600 ([iso6_defaults], [iso6]). --log-file is opened O_NOFOLLOW\|O_CLOEXEC, only as a regular file this process owns, then made 0600 (ADR-0146). |
| ISO-8 | The worker filter admits socket() for AF_UNIX/AF_INET/AF_INET6, answers AF_NETLINK with EAFNOSUPPORT, and kills on any other family; socketpair() is AF_UNIX only. The unit adds RestrictAddressFamilies=, RestrictNamespaces= and the kernel-protection directives. The peer-authentication caveat is documented. Tested in forked children against the real kernel ([iso8]). ADR-0147. |
| DB-10 | SQLite opens with SQLITE_OPEN_NOFOLLOW after descriptor-relative, no-follow traversal; files are 0600; secure_delete=ON and trusted_schema=OFF ([db10]). Residual: the path is reopened by name after the checks. |
Fixed — low, packaging, 15 October 2026¶
| ID | Resolution |
|---|---|
| OPS-5 | scripts/build-deb.sh, the three BSD package scripts and the Dockerfile pass --buildtype=release (the static script already did; the RPM specs get -O2 from %optflags); meson.build keeps Meson's debug default for development builds and warns when hardening meets optimization=0 (ADR-0148). The Dockerfile pins debian:trixie-slim by index digest, builds with libpq, OpenSSL, libpng and libturbojpeg, ships merovingian-db-migrate, merovingian-fed-worker and merovingian-thumbnail-worker with their runtime libraries, and fails its own build on an unresolved ldd entry. scripts/check-startup-hardening-gate.sh boots a packaged server and passes only when every startup hardening check reports enabled and it stops cleanly; the deb, debian-pkg and new docker-image jobs run it against the installed package and the built image. Tested by test_packages_workflow.py and test_startup_hardening_gate.py. The BSD package jobs do not run the gate yet. |
Fixed — low, membership atomicity, 15 October 2026¶
| ID | Resolution |
|---|---|
| DB-4 | database::MembershipProjection writes the membership row (upsert) and the invite change (upsert, or keyed delete for join/leave/ban) in the same transaction as the member event and its current_state row, on SQLite and PostgreSQL. Every member-event path passes one: persist_composed_event, the federated join_room, inbound send_join/send_leave/send_knock, /invite and /send PDUs; a federated leave and /invite for a held room use store_membership_projection. hydrate_local_database runs reconcile_memberships_with_current_state for main's full load profile only (never in the federation worker) before building the roster: a missing row, a join row the current member event does not support, or a row older than that event is rewritten; a newer row is kept. Tested by a tampered SQLite store restarted through the client API, a SQLite trigger refusing the row during a ban, a gated PostgreSQL trigger variant (run against the local test database) and in-memory reconciliation cases ([db4]). ADR-0149. Residual: state-group, extremity and recomputed current-state rows still commit separately from the event. |
Fixed — low, completed partials, 15 October 2026¶
| ID | Resolution |
|---|---|
| EVT-12 | (a), (b) and (c) as before. (d): RoomVersionPolicy::power_levels_accept_floats (v3–v5) and events::PowerLevelEncoding (integers / plus strings in v1–v9 / plus floats in v1–v5, derived only by power_level_encoding(policy)) make the auth rules, the state resolver's power sort, redaction judgement and push read a float power level as rooms/v3.md says: truncated, Infinity/NaN rejected; a value beyond int64 is also refused (fails closed). Hashing stays strict: Canonical JSON defines no float encoding, so a float-bearing event still cannot be parsed for its event ID or content hash (ADR-0150). Tested by conformance scenarios for v4/v5 acceptance and honouring and v6 rejection ([evt12]); RED: the two acceptance cases were refused. |
| ISO-7 | Master, registration-token, main/worker database URI and bootstrap-password reads were already descriptor-bound. The TLS private key is now read the same way (O_NOFOLLOW, regular, owned by this user, mode 0400, into locked memory) and passed to OpenSSL from a memory BIO instead of SSL_CTX_use_PrivateKey_file. The signing-secret-box and token-HMAC caches took the master key's identity and its bytes from two separate opens, so a file swapped in between could leave a key from one file cached under the other's identity; crypto::open_master_key_file now supplies both from one descriptor. Tested: a group-readable or symlinked TLS key is refused, a 0400 key loads, and material read from an opened master key file is that file's even after a rename replaces the path ([iso7]). RED: both TLS refusals failed; the master-key API did not exist. |
| OUT-3 | The per-recipient in-flight cap (min(8, max_in_flight - 1) batches) was already in place and is now tested. New server.push.max_pushers_per_user (20): POST /pushers/set refuses a new pusher beyond it (400 M_INVALID_PARAM; updates allowed). New push::GatewayCircuitBreaker keyed by gateway host: after server.push.gateway_failure_threshold (3) consecutive transport failures, timeouts or 5xx it skips the host for server.push.gateway_cooldown_seconds (300), then probes once per cooldown; at most 4096 hosts tracked. Tested ([out3]): a 21st pusher refused, five events to a failing gateway reach it three times, a saturated recipient does not block another; breaker unit cases. ADR-0151 (supersedes ADR-0029's unbounded registration). RED: 21st pusher accepted; 5 of 5 notifies sent. |
| MED-4 | Decoding already ran unlocked (0.12.20) with local records revalidated after it (ADR-0145). Now: media::ThumbnailCache, an in-memory LRU (security.media.thumbnail_cache_size, 64 MiB) keyed by source record, size and method, served only after the record is checked servable; security.media.thumbnail_max_concurrent_per_user (2) caps decodes in flight per user or client address (429 M_LIMIT_EXCEEDED beyond it); a remote thumbnail carries the cached local record its bytes were admitted as and is revalidated like a local one, and every thumbnail re-checks the media policy after the decode (ADR-0152). Tested with the real worker and the gate ([med-4-cache], [med-4-concurrency], [med-4-remote]) and LRU unit cases. RED: repeat thumbnail 404 without the worker; a third concurrent decode entered the decoder; a quarantined remote record's thumbnail was served. |
| FED-10 | Every network-capable sender/origin key resolution (direct, relayed, IPC re-verification, backfill/state/auth-chain) is admitted against the source-address budget, the in-flight cap and the server/key failure cache, as recorded in the earlier FED-10 entries (ADR-0132 to ADR-0135). The remaining aggregate gap is closed: each federation worker shard starts with its share of key_resolution_max_in_flight and the per-IP rate (federation::shard_key_resolution_budget, limit / shards rounded down, at least 1), so all shards together stay within the configured limits; main keeps the whole budget (ADR-0153). Tested [fed10-shards]; RED: the split did not exist. The PR #509 review found one path still outside the budgets: /send_join state verification called the raw resolver up to 1024 at a time. It now takes main's shared in-flight slots and honours the server/key failure cache, waiting for a slot rather than being refused and paying no per-source rate, since refusing would drop member events (ADR-0155). Tested [send_join][fed10]; RED: peak concurrency above the cap and a failed pair fetched twice. Residual: main's own budget is separate, so main plus workers may reach twice the limits; the earlier direct state/auth-chain denial and backfill negative-cache coverage suggestions remain coverage, not open defects. |
Fixed — low, previously open, 15 October 2026¶
| ID | Resolution |
|---|---|
| FED-12 | A worker's edu_ingest relay must name a live, still-pending request on the exact IPC channel that delivered it whose origin main itself verified (IpcChannel::matches_pending_origin, checked after the runtime-lock wait); a wrong origin, a completed or expired parent, or an ordinary unsigned request authorises nothing. Main verifies every signed request before forwarding, and EDU-only transactions bypass the worker. Tested through real signature verification, encrypted IPC and reopened SQLite ([fed-12-origin-binding], [fed-12-live-context]). ADR-0142 (accepted). Residual: EDU content is bound to the verified origin, not to the signed transaction body. |
| DB-6 | A state_key containing NUL is rejected in event validation (state_key_contains_nul) and before local composition or createRoom persists anything; NUL in percent-decoded path components is refused (path_component_contains_nul); text BoundValues with NUL are refused by prepared_statement_is_valid. Tested by real SQLite/HTTP admission, query-path and gated PostgreSQL scenarios ([db6]). ADR-0137 (accepted). |
| DB-7 | Persisted writes lease connections from a per-store ConnectionPool capped at database.pool_size (now restart-required) instead of opening one per transaction. PostgreSQL connections take the runtime role once and are reused only while idle with nothing to read, so a server-closed connection is replaced. Tested with 64 concurrent writers against real SQLite and PostgreSQL (at most four connections opened; all pooled backends killed between rounds without a failed write) ([db7]). ADR-0154. |
| DB-8 | A non-loopback PostgreSQL connection must state sslmode=verify-full explicitly; an omitted or parent-default mode, service indirection and insecure opt-outs are refused at the single connect choke point. Documented in the user manual and persistence guide ([db8]). ADR-0138 (accepted). |
| DB-9 | No connection string or option is ever logged or returned: diagnostics carry a fixed redacted identity and generic reasons, which is stricter than logging host and database. Tested with quoted, spaced, escaped and URI passwords reaching real libpq failure at DEBUG ([db9]). ADR-0139 (accepted). |
| OPS-2 | make_audit_event drops the whole query string and keeps a control-safe, UTF-8-safe 255-byte path for every audit target, so durable rows, the in-memory window and the audit.append debug line never carry ?token=. Tested with real rate limiting, an unavailable runtime and SQLite reopen ([ops2]). |
| OPS-4 | Negative token lifetimes are refused with field-specific findings; access lifetimes are capped at 30 days and refresh at 365, so no conversion overflows; /login and /refresh omit expires_in_ms when the access lifetime is 0 (/register issues no refresh tokens). Tested by parser/typed matrices and a real SQLite dispatcher conformance scenario ([ops4-negative-lifetime], [ops-4-upper-bound], [ops-4]). ADR-0141. |
FED-10 history — low, federation key admission, 11 October 2026 (fixed 15 October, see completed partials)¶
| ID | Resolution |
|---|---|
| FED-10 | Core inbound network-capable relayed sender lookups use the source-address budget, per-runtime in-flight cap and server/key failure cache across parallel and authorization fallback paths. A separate network-free verification-key provider preserves fresh remote/owned local cache reads and known-peer rotation; a fresh key no longer exempts production discovery. Raw and verified router paths populate the source key. Cache-served origin calls cannot release another request's reservation, and asynchronous resolver/permit lifetimes are exception-safe. Earlier normal-target runs passed 474 assertions / 5 unit cases, 41 / 3 real database/router integration cases and 963 / 54 related inbound cases. Independent review identified local admission rejection being treated as relay misbehavior and recorded as an accepted transaction. That regression is repaired: explicit outcomes preserve local denial, original-transaction retry and genuine failure penalties. Independent all-target build and normal-target runs passed 681 assertions / 9 unit cases, 122 / 4 integration cases and 1170 / 58 related inbound cases. ADR-0132/0133. Independent review subsequently confirmed the core repair, and its completed WSL checkpoint passed 56 targets with zero failures/timeouts. Main IPC PDU/membership/invite re-verification now shares the admitted key-only helper, with a common unknown-source bucket rather than worker-chosen source identities (ADR-0134). Genuine encrypted-IPC RED had 94 failures; final normal integration runs passed 1228 assertions / 3 new cases and 1350 / 7 total FED-10 cases. A mixed negative-cache/EDU regression exposed a genuine penalty lost before a later main-overload return; its RED failed one assertion and the conditional trust persistence fix passed. Final unit FED-10 passed 716 assertions / 10 cases; related inbound passed 1205 / 59. The first main full run had 55 OK / 1 fixture failure; the 50-PDU membership fixture now uses its already-known cache key without weakening production limits. The fresh completed WSL CLI exited 0: 56 OK targets, zero failed/timed-out targets, all JSON return codes 0; the unit binary retains one skipped platform-dependent case. Completed JSON/text logs are archived and the six focused source/test hashes remain unchanged. Independent source-only review approved the unchanged six-file main slice with no material security/logic findings. Real separate-process worker/socket-HTTP execution is now verified with the production serializer/worker decoder: 262 assertions in one case cover main overload, unchanged-transaction retry, persistence, deferred EDU delivery and deduplication; the earlier substituted IPC fixture remains narrower evidence. Backfill/snapshot/auth-chain verification now shares the admitted key-only helper and common unknown-source bucket; request-local denial stops further recovery and returns the existing PDU overload result, rather than accepted missing state. Genuine TLS/SQLite RED failed 13 of 172 assertions; post-format normal integration passed the new 172 / 1 case, all backfill 2173 / 17 and FED-10 1522 / 8. ADR-0135. The fresh post-backfill full CLI completed exit 0: 56 OK targets, zero failed/timed-out targets, all JSON return codes 0, one skipped unit case. Completed JSON/text logs are archived as fed10-backfill-completed-testlog.{json,txt}; all eight tracked source/test hashes remain unchanged. Independent source-only backfill review approved the unchanged slice with no material security/logic findings. It identified additional coverage gaps: sticky denial with remaining recovery capacity/multiple events, partial-progress outlier retry, direct state/auth-chain denial cases, cache identity/signature/event-time/negative-hit backfill cases, and worker HTTP propagation. Both later test delegates failed provider limits; the parent preserved, inspected and repaired their partial fixtures directly. Post-format independent normal integration passed sticky denial 200 / 1, partial-progress retry 314 / 1, all backfill 2687 / 19, worker/socket-HTTP overload 262 / 1 and total FED-10 2298 / 11. The recovered full checkpoint completed 56 OK / zero failures/timeouts / all return codes 0 with ten tracked source/test hashes unchanged; archived low-recovery-completed-testlog.{json,txt}. These are coverage-only tests for previously repaired production behavior, not new RED/fix evidence. Direct state/auth-chain denial and backfill cache rejection/negative-hit cases remain unexecuted coverage suggestions; aggregate cross-shard admission and whole-finding acceptance remain pending. No BSD execution is claimed. |
Outstanding — medium¶
None.
Outstanding — low¶
No low finding is partly fixed.
No low finding is open.
The deny_ip_ranges documentation correction (OUT-8) and fail-closed token issuance
correction (CRY-6) are accepted on the current branch. ISO-4 and OPS-2 corrections
have not yet been accepted; AUTH-2 was corrected in 0.12.23 and CSAZ-11 in 0.12.25.
Area 1 — Authentication, sessions, application-service authentication¶
Result: 12 findings confirmed (2 high, 3 medium, 7 low); none refuted. Two of the 12 came from the verifier. Every prior-audit fix in this area still holds: H-01, H-02, M-01, M-02, M-04, M-05, L-01, L-02, L-03, M-11, L-10.
What is sound: access tokens are 32 random bytes, stored as keyed BLAKE2b hashes, compared in constant time and revoked one way. The masquerade-token spoofing guard holds.
AUTH-1 — Unauthenticated requests write unbounded, synchronous audit rows under the global lock¶
- Severity: high · Attacker: A1 · Verdict: confirmed (orchestrator re-checked)
- Location:
src/homeserver/client_server.cpp:2795-2830,src/homeserver/auth_service.cpp:1443-1465,src/homeserver/local_services.cpp:106-121,src/database/persistent_store.cpp:2722-2737 - Rule:
docs/security-coding-rules.md(bounded resources; no heavy work under the global lock). - Path:
- Any request with
Authorization: Bearer <junk>reaches the rate-limit check, which callsauthenticated_userto build the per-user key while the runtime mutex is held (client_server.cpp:8813). - An unknown token calls
log_diagnostic_audit, thenappend_local_audit. That pushes toLocalDatabase::audit_events(an uncappedstd::vector) and runsdatabase::append_audit_event, which commits synchronously (on SQLite, one connection and one commit per row) and pushes toPersistentStore::audit_log, also uncapped. - A request the rate limiter denies writes the
rate_limit.exceededaudit row as well, so denial adds work instead of shedding it. - Failed
/loginalso records the raw clientuseranddevice_idstrings as actor and target.parse_login_bodydoes not bound their length (up to the 64 KiB body cap). That path is throttled per IP at 20/min. - Why reordering alone does not fix it: a denied request still writes an audit row. The root cause is that A1 can trigger audit appends that are unbounded, synchronous and made under the lock.
- Impact: permanent memory and table growth, plus a DB commit under the global lock
on every junk request.
max_connections_per_ip(64) limits concurrency, not rate. - Fix:
- Bound the in-memory audit containers (the durable copy lives in the DB).
- Record per-request unauthenticated rejections as counters, or as sampled or rate-capped audit rows written outside the runtime mutex.
- Truncate attacker-controlled audit fields to a fixed length.
- Test: GIVEN a runtime with an audit sink WHEN 10 000 requests carry an unknown bearer
token THEN in-memory audit storage stays within a documented cap AND fewer than 10 000
synchronous audit commits occur AND a
/loginwith a 60 KiBuserrecords an actor of at most 255 bytes.
AUTH-11 — Every committed statement, parameters included, is kept in memory for the life of the process¶
- Severity: high · Attacker: A2 (any write), and A1 through AUTH-1 · Verdict: found by the verifier, confirmed by the orchestrator
- Location:
src/database/persistent_store.cpp:549-552;include/merovingian/database/persistent_store.hpp:942-948 - Path:
commit_persistent_transactionappends everyPreparedStatementit persists, including all bound parameters, toPersistentStore::prepared_statements.- The only reader is
sensitive_values_are_redacted(persistent_store.cpp:3706-3707), a check used by tests. Nothing trims the vector. - Impact:
- Memory grows with every database write the server ever makes: every event, receipt, audit row and token.
- Password hashes and token hashes stay resident in heap memory indefinitely.
- Every write-path finding in this report (AUTH-1 included) is amplified by it.
- Fix: do not keep committed statements in production. If a test needs to inspect them, give the test an explicit, bounded capture hook.
- Test: GIVEN a store with no capture hook WHEN 10 000 transactions commit THEN the retained statement count is zero (or bounded by a fixed cap).
- Note: Area 10 (database) re-examines this alongside the other persistence paths.
AUTH-4 — Argon2id verification for unauthenticated requests runs under the global runtime mutex¶
- Severity: medium · Attacker: A1 · Verdict: confirmed
- Location:
src/homeserver/auth_service.cpp:1029-1031(login, including the dummy hash for unknown users)src/homeserver/auth_service.cpp:948-951(registration token)src/homeserver/client_server.cpp:9132-9146(registration-token validity endpoint)src/http/rate_limit.cpp:60-64(rate-limit tiers)- Path:
- No login or register path releases the runtime lock (
RuntimeLockRelease) aroundcrypto_pwhash_str_verify(OPSLIMIT_INTERACTIVE/MEMLIMIT_INTERACTIVE). - Each unauthenticated verification therefore stalls every client and federation request.
rate_limit_tier_formatches only/_matrix/client/v3/register./_matrix/client/v1/register/m.login.registration_token/validitytherefore falls into the generic tier (90/min per IP instead of 20/min).- That handler runs an Argon2 verify without checking whether registration is enabled.
- Spec: that endpoint is marked "Rate-limited: Yes" ("Servers should be sure to rate limit this endpoint"). A limit exists, so the tier choice is not a spec violation.
- Impact: a modest set of source addresses can serialise the whole server behind password hashing.
- Fix:
- Snapshot the stored hash, release the runtime lock, verify, then re-acquire.
- Bound concurrent hash work with a semaphore that sheds load with 429.
- Put
/v1/register/*in theauth_sensitivetier. - Refuse the validity endpoint with 403 when registration is disabled.
- Test: GIVEN 64 concurrent failed logins from distinct addresses WHEN
/versionsis requested THEN it completes within a small bound; AND the validity path is classifiedauth_sensitive.
AUTH-3 — An application service's sender_localpart user is neither reserved nor created¶
- Severity: medium · Attacker: A1 or A2 (only where registration is open or token-gated) · Verdict: confirmed · Status: fixed in 0.12.19
- Location:
src/homeserver/client_server.cpp:9480-9487(ordinary registration checks onlynamespaces.users)src/homeserver/client_server.cpp:8712-8716(the sender is the default masquerade identity)src/appservice/registration.cpp:544-546(comment wrongly says "created at startup")- Path:
- Ordinary
/registerrejects a name only if it matches another service's exclusiveusersregex. - The spec defines
usersas "in addition to itssender_localpart", so a sender localpart outside that regex is accepted. - The registrant then owns the account the bridge acts as by default: its password,
its
/syncand its room memberships. - Precondition: the sender localpart falls outside the service's own exclusive regex.
The spec's example (
_irc_botvs@_irc_.*) is protected. - Impact: takeover of the bridge bot identity and read access to its rooms.
- Fix:
- At startup, create or verify every registration's sender as a passwordless application-service user.
- Reject ordinary
/registerand/register/availablefor any sender user ID withM_EXCLUSIVE. - Correct the code comment.
- Test: GIVEN an appservice whose
sender_localpartisbridgebotand lies outside its namespace WHEN an ordinary user registersbridgebotTHEN 400M_EXCLUSIVEAND after startup@bridgebot:<server>exists with no password.
AUTH-6 — Application-service user namespaces have no local-server check¶
- Severity: medium · Attacker: A4 · Verdict: confirmed; the auditor's spec citation was out of context
- Location:
src/appservice/registration.cpp:107-120and:150-158src/homeserver/client_server.cpp:8721src/homeserver/auth_service.cpp:1427,:1519- Path:
appservice_owns_userregex-matches the whole user ID.- The spec's own example regex (
@_irc_bridge_.*) has no domain anchor, so?user_id=@_irc_bridge_x:remote.orgis accepted as the masqueraded identity. registration_yaml.cppvalidates regex syntax only.- Spec: identity assertion requires the user to be "covered by one of the application
service's
usernamespaces". Namespaces concern local users ("application services can only register interest in local users"). - Impact: a compromised appservice can act locally as a foreign user, and those events are signed by this server.
- Fix: require the local server name in
appservice_owns_userand every masquerade, login and register path, before the regex test. - Test: GIVEN an appservice with regex
@_x_.*WHEN it asserts?user_id=@_x_a:other.orgTHEN 403M_FORBIDDEN.
AUTH-2 — Anyone can hold any account in login lockout, and the documentation says otherwise¶
- Severity: low (a documented trade-off; one doc claim is false) · Attacker: A1 · Verdict: adjusted from medium
- Location:
src/homeserver/auth_service.cpp:161-235,:1009-1044,:1859-1876;docs/auth-identity.md:509-541 - Detail:
- The throttle is keyed on the claimed user ID only.
- Five failures refuse all logins for 15 minutes, correct passwords included, at a cost of 5 requests per 15 minutes per victim.
- User-interactive auth (UIA) password checks share the counter, so the victim also cannot change their password, deactivate, or delete devices.
docs/auth-identity.mdsays the lockout is bounded "by a real login clearing it immediately", which cannot happen while locked. The same document later says a correct password during lockout is still refused, so it contradicts itself.- Fix:
- Correct the document.
- Key failures on (account, source) with a higher per-account ceiling.
- Do not let unauthenticated failures block UIA from an already-authenticated device.
- Test: GIVEN 5 failed logins for U from address A WHEN U's authenticated device performs a UIA password check THEN it is not refused with 429.
AUTH-5 — 3PID ownership is never validated¶
- Severity: low · Attacker: A2 · Verdict: adjusted from medium (SHOULD-level; 3PIDs are not used for login or password reset)
- Location:
src/homeserver/client_server.cpp:1900-1902,:9221,:10480-10611 - Detail:
requestTokensessions are created withvalidated_at_ms = nowand nothing is sent./account/3pid/addand/bindaccept any session whosesidandclient_secretmatch.- A user can therefore bind someone else's email address, and the real owner is then
refused with
M_THREEPID_IN_USE. - Spec: "The homeserver should validate the email itself, either by sending a validation email itself or by using a service it has control over."
docs/auth-identity.md:37("local validation sessions") overstates what exists.- Fix: create sessions unvalidated. Refuse
/addand/bindwithM_SESSION_NOT_VALIDATEDuntil an out-of-band proof arrives. - Test: GIVEN a fresh
requestTokensession WHEN/account/3pid/addis called THEN 400M_SESSION_NOT_VALIDATEDand nothing is bound.
AUTH-7 — Appservice register, login and masquerade ignore other services' exclusive namespaces¶
- Severity: low · Attacker: A4 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:9387,:9650,:8721;src/appservice/registration.cpp:483-521 - Detail:
- None of these paths calls
user_namespace_exclusively_owned_by_other(user, registration.id). - Overlap detection catches only identical regex strings.
- Spec: "An exclusive namespace prevents humans and other application services from creating/deleting entities in that namespace."
- Fix: apply the exclusivity check on appservice register and login.
- Test: GIVEN service A (
@.*, non-exclusive) and service B (@_b_.*, exclusive) WHEN A registers_b_xTHEN 403M_EXCLUSIVE.
AUTH-8 — Identity assertion is honoured on 3PID management endpoints¶
- Severity: low · Attacker: A4 · Verdict: adjusted (narrower scope)
- Location:
src/homeserver/client_server.cpp:10524,:10612,:10699,:10796 - Detail:
- Spec: identity assertion "applies to all aspects of the Client-Server API, except for Account Management".
- Password change, deactivate and
/3pid/addare protected by password UIA. /3pid/bind,/3pid/delete,/3pid/unbindand the deprecatedPOST /account/3pidare not, and accept a masqueraded identity.- Fix: refuse masquerade tokens on
/account/*exceptwhoami. - Test: GIVEN a masqueraded request WHEN
POST /account/3pid/deleteTHEN 403.
AUTH-9 — Control characters in login fields reach text logs unescaped¶
- Severity: low · Attacker: A1 · Verdict: confirmed
- Location:
include/merovingian/observability/logger.hpp:368-374,:703-712;src/homeserver/auth_service.cpp:1000-1004,:1036-1048 - Detail:
- A JSON
\ninidentifier.userordevice_idforges extra log lines. - Redaction works by key only; no control-character escaping exists in
src/observability/. - Audit database rows are bound parameters and are not affected.
- Fix: escape control characters and cap the length of every structured log field value.
- Test: GIVEN a login whose
usercontains\nWHEN diagnostics are captured THEN each event is one physical line.
AUTH-10 — The failed-login map is unbounded and scanned in full on every failure¶
- Severity: low · Attacker: A1 · Verdict: confirmed
- Location:
src/homeserver/auth_service.cpp:185-195,:219-233;include/merovingian/homeserver/runtime.hpp:349 - Fix: cap the map, hash or truncate keys, and expire entries from a time-ordered queue.
- Test: GIVEN 100 000 failed logins for distinct IDs THEN the map never exceeds its cap.
AUTH-12 — An appservice can act as a deactivated or non-existent user¶
- Severity: low · Attacker: A4 · Verdict: found by the verifier
- Location:
src/homeserver/auth_service.cpp:1426-1436,:1879-1897 - Detail:
- The masquerade branch of
authenticated_userdoes not look the user up. account_state_for_usergates only locked and suspended accounts.- Fix: refuse masquerade as a deactivated user. Decide explicitly whether
non-existent users in a namespace may be asserted; the spec allows implicit
registration only through
/register. - Test: GIVEN a deactivated user in an appservice namespace WHEN the service asserts that user THEN 403.
Area 1 — correctness notes (not security findings)¶
upgrade_v3_access_token_to_v4(auth_service.cpp:504-548) passes astring_viewinto the string it then overwrites. The persisted upgrade therefore never happens. The path is legacy-only and has no security impact. Copy the value first.server_name_from_user_idusesrfind(':'). On ahost:portserver name, lock and suspend fail closed with a spuriousM_INVALID_PARAM(client_server.cpp:1015,:1285;local_http_router.cpp:544).change_local_user_passworddiscards revocation persistence failures withstd::ignore(auth_service.cpp:1773,:1826-1836). After a database fault, revoked sessions come back on restart.register_local_userpasses a hard-coded"127.0.0.1"to the registration policy (auth_service.cpp:941). No policy uses the value, so it only misleads diagnostics.
Area 1 — coverage gaps¶
- Not examined:
src/appservice/registration_yaml.cpp,appservice_client.cpp,src/identity/, token hydration at startup, andmasquerade_token.cppdelimiter handling. src/auth/key_api.cppand the E2EE key handlers are left to Area 2.
Area 2 — Client-Server authorisation and room access control¶
Result: 12 findings confirmed: 4 high, 4 medium, 4 low. One sub-claim was refuted (see the end of this area). The earlier report had no findings in this area.
What holds:
- Admin gates.
- Per-user resources:
- filters, account data, tags, pushers and key backup;
- OpenID tokens;
- PUT typing and presence for the caller only;
- /keys/upload user and device binding;
- cross-signing uploads behind UIA.
- Rejected and soft-failed events are withheld (ADR-0064 B2) on /messages, /context,
/event, /sync, search and sliding sync.
CSAZ-1 — Sliding sync serves any room to any user who names it¶
- Severity: high
- Attacker: A2. Most realistically a former, kicked or banned member who still knows the room ID.
- Verdict: confirmed; re-checked by the orchestrator.
- Location:
src/homeserver/client_server.cpp:4598-4605(subscription IDs added to the response set)src/sync/sliding_sync_parser.cpp:277-281,:467-480src/sync/sliding_sync_room_builder.cpp:600-805src/sync/sliding_sync_extensions.cpp:213-290- Spec: "In all cases except
world_readable, a user needs to join a room to view events in that room." - Path:
POST /_matrix/client/v4/synccarries{"room_subscriptions":{"!victim:srv":{"required_state":[["*","*"]],"timeline_limit":1000}}}. There is no feature flag.- Every subscription key is appended to
response_room_idswithout a membership check. build_room_responsereturns the name, heroes, member counts, all state that matches the wildcard, and up totimeline_limitevents (the limit is unclamped).- The
receiptsandtypingextensions return whatever rooms the client names inextensions.*.rooms. That includes other users'm.read.privatereceipts (see CSAZ-4). - The list path is limited to joined rooms (
sliding_sync_room_list.cpp:447-459), so the missing check here is an omission, not a design choice. - Impact:
- The room ID is the only secret. Rooms on room versions below 12 get sequential IDs
(
!room<N>:server,room_service.cpp:2547-2548). - Each subscription scans
store.eventsin full, so a single request is also a CPU amplifier. - Existing test:
tests/integration/test_sliding_sync_flow.cpp:1745-1800subscribes an invited, not-joined user and receives the room. It asserts ignore-list behaviour, not refusal. - Fix:
- Drop subscription and extension rooms the user has not joined; invitees get stripped state only.
- Clamp
timeline_limit, the number of subscriptions and the number ofrequired_stateentries. - Test: GIVEN alice in a private room and mallory in no room WHEN mallory subscribes
to that room with
required_state [["*","*"]]and names it in the receipts and typing extensions THEN the response has no entry for it AND both extensions are empty.
CSAZ-2 — initialSync and /members admit any membership row, including after leave or ban¶
- Severity: high
- Attacker: A2 — a knocker, invitee, or banned or departed user.
- Verdict: adjusted; the
/state/{type}/{key}sub-claim was refuted. Re-checked by the orchestrator. - Location:
src/homeserver/client_server.cpp:12938-12963(initialSync gate):7428-7514(initialSync response):12691-12769(/members)- Spec:
- "After a user has left a room, they may see any events which they were allowed to see before they left the room, but no events received after they left."
/members200: "If you have left the room then this will be the members of the room when you left."- Path:
- The initialSync gate is
is_or_was_member = membership.has_value(), so invite, knock, ban and leave rows all pass. room_initial_sync_jsonthen returns the latest 100 events and the full current state, with no leave cut-off./membersaccepts any row, always returns the current roster (ban reasons included), and ignoresat.- On a room with a
knockjoin rule, anyone can create such a row by knocking. - Impact:
- Banned and departed users keep reading new messages, state and the roster indefinitely.
- Knockers read rooms they have not been admitted to.
- Fix:
- Add one
may_read_room(user, room, at)predicate: joined users see the current state; users who left after joining see state as of their leave event; invite, knock and never-joined leave see stripped state only. - Apply it to initialSync,
/members,/stateand/state/{type}/{key}. - Honour
aton/members. - Test:
- GIVEN a knock room with history WHEN mallory knocks and calls initialSync and
/membersTHEN both return 403. - GIVEN bob was banned WHEN a new message is sent and bob calls initialSync THEN the new message is absent.
CSAZ-3 — m.room.history_visibility is not enforced on any client read path¶
- Severity: high · Attacker: A2 · Verdict: confirmed (orchestrator grep: the
setting is read only for
/publicRooms, the initialSync peek and the space hierarchy) - Location:
src/homeserver/client_server.cpp:6514-6616(/messages),:6676-6796(/context),:12326-12360(/event),:3798-3903(/synctimeline),:7171+(search)src/homeserver/room_service.cpp:6414-6612(/relations,/threads)src/sync/sliding_sync_room_builder.cpp:674-721- Spec: "The rules governing whether a user is allowed to see an event depend on the
state of the room at that event." The spec then gives five allow and deny rules for
world_readable,join,shared,invited, and otherwise. - Impact:
- In a room set to
joinedorinvited, a newcomer reads the entire prior history. The owner's privacy setting has no effect. - The default (
shared) is unaffected. - No document lists this as a gap, and no test asserts enforcement.
- Fix:
- Add a per-request
event_visible_to(user, event)that implements the five rules against the state at each event, using the existing state groups. - Apply it on every read path, including the state returned by
/contextand the/syncjoin snapshot. - Test: GIVEN a room with visibility
joined, message M1, then bob joins WHEN bob calls/messages,/contexton M1,/eventon M1,/search,/relations,/syncand sliding sync THEN M1 is absent or refused. WHEN the visibility issharedTHEN M1 is present.
CSAZ-4 — m.read.private and m.fully_read receipts are sent to every room member¶
- Severity: high (spec MUST) · Attacker: A2 · Verdict: confirmed; re-checked by the orchestrator
- Location:
src/homeserver/client_server.cpp:3473-3517,:13210-13369;src/sync/sliding_sync_extensions.cpp:225-290 - Spec:
- "Servers MUST NOT send the
m.read.privatereceipt to any other user than the one which originally sent it." - "
m.fully_readdoes not appear underm.receipt". - Path: the
/syncand sliding-sync receipt builders index every stored receipt of the room by type and user. They filter only on the stream position and the ignore list. - Existing test:
tests/conformance/test_client_server_conformance.cpp:10164-10225has a comment claiming "not visible to other members" but tests only the owner's notification count. - Fix:
- One shared helper: send
m.readto other users,m.read.privateto its owner only, and neverm.fully_readinm.receipt. - Deliver
m.fully_readto its owner as room account data. - Test: GIVEN alice and bob in a room WHEN alice sends an
m.read.privatereceipt THEN bob's/syncand sliding sync contain no alice receipt, while alice's own/syncdoes.
CSAZ-5 — /publicRooms ignores directory visibility¶
- Severity: medium · Attacker: A1 · Verdict: adjusted from high
- Location:
src/homeserver/client_server.cpp:3081-3160,:10277-10304,:11793-11805include/merovingian/homeserver/runtime.hpp:126- Spec:
- "
private: The room will be hidden from the published room directory." - "a visibility setting of
publicshould not be confused with apublicjoin rule". - Detail:
- The listing selects rooms by
join_rule == public, anddirectory_publicis never consulted. createRoom'svisibilityonly picks the preset.- The flag is in memory only and is lost on restart.
POST /publicRoomsis also routed before authentication, although the spec says it "Requires authentication: Yes". UnauthenticatedGETalready returns the same data, so this is a deviation only.- Impact: rooms the owner chose to keep out of the directory are listed with name, topic, alias and member count. The auditor rated this high; it is medium because it exposes metadata only, and these rooms are already joinable by anyone who knows the ID.
- Fix:
- Persist a directory flag, set it from
createRoomvisibility, and list only flagged rooms. This needs a numbered migration, whichmigrations/AGENTS.mdrequires to be approved first. - Require authentication on
POST. - Test: GIVEN a
public_chatroom created with the default visibility WHENGET /publicRoomsis called THEN the room is absent AND an unauthenticatedPOSTreturns 401.
CSAZ-7 — Invite and knock "stripped state" is the whole current state, as full events¶
- Severity: medium · Attacker: A2 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:1073-1095,:4016-4026;src/homeserver/room_service.cpp:846-867 - Spec: "Stripped state events can only have the
sender,type,state_keyandcontentproperties present." - Impact: a knocker receives every member event, the power levels, the ACLs and more, each with event IDs, hashes and signatures.
- Fix: build the stripped set: create, name, avatar, topic, join rules, canonical alias, encryption, and the user's own member event, each reduced to the four permitted keys.
- Test: GIVEN a knock room with 3 members WHEN mallory knocks and syncs THEN
knock_statehas no foreignm.room.memberand nom.room.power_levels, and no event carriesevent_idorhashes.
CSAZ-8 — /sync sends every known user's presence to every user¶
- Severity: medium · Attacker: A2 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:3599-3640,:13548-13588 - Spec: "Presence information is published to all users who share a room with the target user."
- Detail:
- Only the caller's own presence is excluded; there is no shared-room filter.
PUTpresence accepts anypresencestring and astatus_msgof any length up to the body cap.- Fix:
- Send presence only for users who share a joined room with the viewer.
- Validate
presenceagainstonline,unavailableandoffline. - Cap
status_msg. - Test: GIVEN alice and bob with no common room WHEN alice sets a status message THEN
bob's
/synchas no presence from alice; ANDpresence:"x"returns 400.
CSAZ-10 — To-device messages to non-existent recipients are queued forever¶
- Severity: medium · Attacker: A2 · Verdict: confirmed for to-device; the other sub-claims are only partly verified · Status: fixed in 0.12.19
- Location:
src/homeserver/client_server.cpp:5972-6083;src/database/persistent_store.cpp:2853-2934;src/federation/key_signatures.cpp:90-135 - Detail:
- No check that the target user or device exists.
- The drain deletes only rows that a real device has acknowledged, so rows for wildcard targets or non-existent devices are never removed.
- One-time keys and key-signature uploads have no per-user cap, and each
/keys/queryscans every signature upload under the global lock. - Fix:
- Reject or drop messages for unknown local users and devices.
- Cap the per-recipient queue and add a TTL.
- Cap one-time keys, signatures and filters per user.
- Index signatures by target.
- Test: GIVEN a user WHEN they send 10 000 to-device messages to a non-existent device THEN the store stays bounded.
CSAZ-6 — Membership changes sent through PUT /state/m.room.member skip the membership projection¶
- Severity: low · Attacker: A2 (the effect is a failed moderator action) · Verdict: adjusted from medium
- Location:
src/homeserver/client_server.cpp:12246-12280;src/homeserver/room_service.cpp:6041-6207 - Detail:
- A ban or kick sent through the state API changes room state.
- It does not update
membershipsorLocalRoom.members, so the banned user keeps read access through every gate keyed on those. - Federated membership events are projected correctly.
- Fix: project membership at one choke point for every accepted
m.room.memberevent, and make read gates consult resolved state. - Test: GIVEN bob joined WHEN a moderator bans him through
PUT /state/m.room.member/@bobTHEN bob's next/messagesreturns 403.
CSAZ-9 — Locally created events are not checked against the spec size limits¶
- Severity: low · Attacker: A2 · Verdict: adjusted from medium
- Location:
src/homeserver/room_service.cpp:1303-1518;include/merovingian/events/limits.hpp:96,:105 - Spec: "The complete event MUST NOT be larger than 65536 bytes";
typeandstate_key"MUST NOT exceed 255 bytes". - Detail: only inbound federation enforces the limits. A body near the 64 KiB cap,
or a long
typeorstate_keyin the URL, produces a PDU that other servers reject. - Fix: enforce the limits on the composed, signed event in
compose_signed_eventand returnM_TOO_LARGE. - Test: GIVEN a member WHEN the composed event is 65 537 bytes, or its
state_keyis 256 bytes THEN 400M_TOO_LARGEand nothing is stored.
CSAZ-11 — Redactions are accepted and relayed but never applied¶
- Severity: low · Attacker: A2 or A3 · Verdict: adjusted
- Location:
src/events/authorization.cpp:1505-1511;docs/todos/capability-gaps.md:95 - Spec:
rooms/v12.md"Handling redactions"; client-server "Redactions": "This stripped down event is thereafter returned anytime a client or remote server requests it." - Detail:
PUT /redactis documented as not started.- What is not documented is that
m.room.redactionevents arriving through/sendor federation are relayed without a validity check and never applied, so the original content is still served. - Fix: apply valid redactions (the
redacted_becausefield), relay only valid ones, and correct the capability-gaps row. - Test: GIVEN alice's message M WHEN alice redacts it THEN
/eventon M returns the redacted form.
CSAZ-12 — Missing membership or power checks on /report, /upgrade, directory visibility and aliases¶
- Severity: low · Attacker: A2 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:8269-8286,:13383-13456,:10277-10304,:10225-10273 - Detail:
- (a)
/reporthas no membership or existence check. Spec: "The caller must be joined to the room to report it." Each report is also an unbounded audit append. - (b)
/upgradechecks only that the caller is joined, and discards the tombstone result, so a PL-0 member gets 200 and a new room that names the old one as its predecessor. - (c) Any joined member can change the directory visibility.
- (d) Alias
PUTdoes not validate the alias grammar or domain, and has no power check, so any user can take#admin:<server>. - Fix:
- Require membership for
/report. - Make
/upgradefail with 403 unless the tombstone succeeds. - Gate directory changes on power level.
- Validate the alias grammar and require the local domain.
- Test: GIVEN a PL-0 member WHEN they call
/upgradeTHEN 403 and no new room exists.
Area 2 — refuted¶
- CSAZ-2,
/state/{type}/{key}sub-claim. The gate admitsjoinandleaveonly, and returns state as of the leave point, as the spec requires ("If the user has left the room then the state is taken from the state of the room when they left").
Area 2 — hardening notes (not verified)¶
- The sequential room-ID counter is the size of a vector that
rooms.erasecan shrink, so a latercreateRoomcan collide (room_service.cpp:2547,:3816). /relations,/threadsand the initialSync chunk do not skip rejected or soft-failed events (ADR-0064 B2)./user_directory/searchreturns every local profile. The spec permits this, but it is an enumeration surface.- Cross-signing uploads do not check the embedded
user_idorusage.
Area 2 — coverage gaps¶
- Read by grep only:
src/sync/sync_filter.cpp,stream_token.cpp,sync_notifier.cppanddevice_list_delta.cpp. - Not read: the
src/trust_safety/internals and the/syncleave timeline for departed users.
Area 3 — HTTP transport, TLS and request-level DoS¶
Result: 8 findings confirmed (3 high, 3 medium, 2 low). None were refuted; one additional finding came from the verifier.
Prior fixes re-checked, all hold: H-03, M-02, M-03, L-04, L-05 and M-07. Also holding:
- request-smuggling defences: Transfer-Encoding refused, duplicate or mismatched
Content-Length, obs-fold, LF-only line endings, closing after a parse error;
- X-Forwarded-For handling, walked right to left over trusted proxies;
- response-header CRLF validation;
- JSON depth and member caps, duplicate-key refusal and UTF-8 validation.
Root cause shared by HTTP-1 and HTTP-2: the main request pool is a hard-coded 8
threads (src/main.cpp:742), and a worker stays tied to its connection through idle,
partial-read and outbound-wait phases.
HTTP-1 — One client can hold every worker in the main request pool¶
- Severity: high · Attacker: A1 · Verdict: confirmed (orchestrator re-checked the pool size, defaults and the parking loop)
- Location:
src/homeserver/http_server.cpp:594-653(wait_for_next_request),:1653-1737(serve_connection),:811-883(read_remaining_body),:1322-1346include/merovingian/config/config.hpp:64-67;src/main.cpp:742- Rule: the claims this finding contradicts:
docs/threat-model.md:1137-1171says the parking cap stops "a single client [from converting] open sockets into held worker threads beyond the operator's budget", and that "the cap bounds one host's share".- ADR-0072 says the same of the per-IP connection cap.
- Path — keep-alive parking (fully protocol-conformant):
- The default
keep_alive_max_connectionsis 8, the same as the pool size. Validation is range-only and does not relate the two. - A parked connection blocks in
poll()on its own pool worker. - A client opens 8 connections and sends one small request on each every ~14 s (the idle window is 15 s). Each worker serves the request and parks again.
- All 8 workers stay occupied indefinitely. New connections wait in the FIFO queue, which has no timeout. The cost is about 0.6 requests per second and nothing that looks like slowloris.
- Path — slow body:
- The body deadline is
30 s + Content-Length/16 KiB, with only a 5 s gap check between bytes. - For the two media upload routes, the transport cap rises to
max_upload_size(50 MiB) before authentication. - One byte every 4.9 s therefore holds a worker for about 3230 s. Other POSTs hold one for about 94 s.
- Path — idle connect and handshake: an idle connect holds a worker for 5 s, a
dribbled request head for 30 s, and a dribbled TLS ClientHello for 15 s. The TLS
handshake runs on the worker (
http_server.cpp:2014). - Impact: 8 sockets from one address stall every client and inbound federation request on every listener. The per-IP cap (64) is eight times the pool.
- Fix:
- Stop tying a worker to idle or partial-read phases: park and read request heads on a poll/epoll reactor, and dispatch only complete request heads.
- Enforce a real minimum body rate.
- Authenticate and rate-limit from the request head before reading any large body.
- Cap requests and lifetime per connection.
- Cap worker-holding connections per client well below the pool size, and make the pool size configurable.
- Correct the threat-model text and record the design as an ADR.
- Test: GIVEN a pool of N workers WHEN one client holds N keep-alive connections, sending one request every idle−1 s, or trickles N request bodies THEN a request from a different client completes within 1 s.
HTTP-2 — Unauthenticated directory endpoints pin workers on blocking outbound federation calls¶
- Severity: high · Attacker: A1 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:8892-9036:GETandPOST /publicRooms?server=, andGET /directory/room/{alias}for a remote aliassrc/homeserver/room_service.cpp:1656-1769src/homeserver/worker_pool.cpp:1387-1418include/merovingian/config/config.hpp:353- Path:
- These routes are served before the authentication gate.
perform_sync_outbound_callruns server discovery, then a federation request, synchronously on the request thread.remote_timeoutdefaults to 60 s, and the federation-worker round trip waits up tototal_timeout + 10s.RuntimeLockReleasefrees the runtime mutex, not the thread.- No concurrency cap applies to this path.
- The generic rate tier allows 90 requests/min, and the alias route escapes it entirely through HTTP-3.
- Spec: unauthenticated access to these endpoints is permitted, so this is a design flaw, not a conformance defect.
- Impact: a server under the attacker's control that never answers pins all 8 workers at about 8 requests per minute. It also makes this server send signed requests on the attacker's schedule.
- Fix:
- Run proxy calls on a bounded, separate pool (or asynchronously), with a global and a per-client in-flight cap well below the pool size.
- Use a short deadline and answer 429 or 502 when saturated.
- Consider an operator switch, or requiring authentication, for remote proxying.
- Test: GIVEN a peer that never answers WHEN 20 unauthenticated
publicRooms?server=requests arrive THEN/versionsstill answers promptly AND the requests over the cap get 429.
HTTP-5 — SIGPIPE is not ignored in the server, so a TLS client that resets its connection can kill the process¶
- Severity: high (medium where systemd's default
IgnoreSIGPIPE=yesapplies) · Attacker: A1 · Verdict: confirmed by code reading, not reproduced - Location:
src/main.cpp: no SIGPIPE handling.src/homeserver/tls.cpp:165-166,:290:SSL_set_fdgives OpenSSL's socket BIO, which useswrite()withoutMSG_NOSIGNAL.src/homeserver/http_server.cpp:1171-1176,:1276-1287: an error response is written after a failed read.src/media/thumbnailer.cpp:470: the onlySIG_IGN, reached lazily on the first thumbnail.- Rule:
docs/hardening.md:174-178says "SIGPIPEis ignored so a worker that dies mid-request cannot terminate the parent". The server does not do this at startup. - Path:
- The client completes a TLS handshake and sends a partial request head, then resets the connection.
- The read fails, and the server writes a 408 through
SSL_write_ex. - That
write()returnsEPIPEand raises SIGPIPE. The default disposition terminates the process. - The plain-HTTP and IPC paths use
MSG_NOSIGNALand are not affected. tests/integration/test_main.cpp:15ignores SIGPIPE for the whole test binary, which hides the defect.- Exposure: exposed under OpenRC, BSD
rc.d, containers and manual runs. The shipped systemd unit relies on the systemd default; it sets nothing itself. - Impact: one unauthenticated TLS connection terminates the homeserver.
- Fix: set
sigaction(SIGPIPE, SIG_IGN)in each executable'smain()(server, federation worker, thumbnail worker,db-migrate) before any thread starts. - Test: GIVEN the server started with the default SIGPIPE disposition and a TLS
listener WHEN a client sends a partial request head and resets THEN the process
survives and serves the next request. The test must not inherit the harness's
SIG_IGN.
HTTP-3 — Rate-limit buckets are keyed on the raw path, so varying a path segment bypasses them¶
- Severity: medium · Attacker: A1 and A2 · Verdict: adjusted; the auditor understated the scope
- Location:
src/homeserver/client_server.cpp:2651-2742(normalized_target),:2779-2811;include/merovingian/http/rate_limit.hpp:338-376 - Rule:
docs/http-transport.md:490-492,:546says path parameters such as media IDs are coalesced. - What is coalesced: only the room ID (not the suffix after it), the device ID, the
user ID in
/user/{userId}/…, profile, join, knock, v1 relations and/_matrix/client/v1/media/*. - What is not coalesced:
/_matrix/media/v3/download|thumbnail/{server}/{id}/directory/room/{alias}sendToDevice/{type}/{txnId}- the
send/{type}/{txnId},state/…andredact/…suffixes - unknown paths
- Detail:
- The per-user key embeds the same normalised path. Varying
txnIdtherefore gives a fresh per-IP and per-user bucket for every message send, so message sending is effectively not rate-limited. - At 100 000 keys, every new key runs two linear passes in
evict_to_make_roomwhile the global runtime mutex is held. - The auditor's "
thirdpartyexempt before auth" claim is narrower than stated: unauthenticated callers still get 401. Authenticated users can drive unlimited appservice lookups, which is by design and low. - Fix:
- Key buckets on a matched route template; send unmatched paths to one shared bucket.
- Make eviction O(1) (LRU or CLOCK), and move
allow()ahead of the global mutex. - Correct
docs/http-transport.md. - Test:
- GIVEN a media tier of 20/min WHEN one IP fetches 100 distinct
/_matrix/media/v3/download/x/<id>THEN the requests over 20 get 429. - GIVEN a message tier WHEN one user sends 200 messages with distinct
txnIdTHEN the tier cap applies.
HTTP-4 — /sync timeout has no upper bound and there is no per-user cap on long polls¶
- Severity: medium · Attacker: A2 · Verdict: confirmed
- Location:
src/homeserver/client_server.cpp:3763-3773,:4568src/sync/sliding_sync_parser.cpp:521-538(digit accumulation without an overflow check)src/homeserver/http_server.cpp:1450-1644- Detail:
- One account opens 32 long polls with enormous
timeoutvalues and fills the 32-thread sync pool. - A queue-full submit falls back to blocking a main-pool worker for the requested time.
std::chrono::milliseconds{uint64}above 2^63 wraps negative.now() + msabove about 9.2e12 ms overflows signed nanoseconds, which is undefined behaviour.- A clamp alone is not enough: 64 thirty-second polls a minute still fit the rate tier.
- Fix:
- Clamp
timeoutto a server maximum using saturating arithmetic, and do the same in the sliding-sync parser. - Cap concurrent long polls per user and device, replacing an older poll from the same device.
- Test: GIVEN a 32-thread sync pool WHEN one user issues 40
/syncrequests with hugetimeoutvalues THEN each returns by the server maximum AND another user's/syncis served.
HTTP-8 — Keep-alive connections have no request-count or lifetime limit¶
- Severity: medium (it is what makes HTTP-1's parking variant indefinite) · Attacker: A1 · Verdict: found by the verifier
- Location:
src/homeserver/http_server.cpp:1653-1710 - Fix: close a connection after N requests or T seconds. This fix belongs with HTTP-1.
- Test: GIVEN a keep-alive connection WHEN it sends more requests than the per-connection cap THEN the server closes it after the cap.
HTTP-6 — Unauthenticated media bodies up to 50 MiB are buffered and copied before authentication¶
- Severity: low · Attacker: A1 · Verdict: adjusted from medium
- Location:
src/homeserver/http_server.cpp:1322-1346;src/homeserver/client_server.cpp:8702 - Detail:
- The transport reads the whole body.
- The dispatcher copies it (
auto req = raw_req) before the 401 is returned. - Peak memory is about 800 MiB across 8 workers. It is only allocated as bytes arrive, so it costs the attacker the bandwidth.
- Fix: raise the transport body cap only after the request head has authenticated.
- Test: GIVEN no token WHEN a client declares a 50 MiB
Content-Lengthto the upload route THEN it gets 401 after reading at most a bounded prefix.
HTTP-7 — parse_json wraps integers above INT64_MAX to negative values¶
- Severity: low (a data-fidelity quirk, not a memory-safety issue) · Verdict: adjusted
- Location:
src/canonicaljson/yyjson_adapter.c:168-176;src/canonicaljson/parser.cpp:184-190 - Detail:
- Client bodies and signed paths use
parse_lossless, which range-checks integers. - The permissive
parse_jsonparses room-tag bodies, stored account data, and some remote and appservice responses. - Fix: reject unsigned values above the canonical range in the adapter.
- Test: GIVEN
18446744073709551615WHENparse_jsonparses it THEN the result isinteger_out_of_range.
Area 3 — hardening notes (not verified)¶
%00decodes to a NUL byte in path and query values (src/core/query_params.cpp:56-99).- Transport error bodies are plain text but are labelled
application/json. - The 1 MiB transport cap is below the largest legal federation
/send(50 PDUs of 64 KiB each). TlsConnection::pumpgrants a fresh 15 s per read.- TLS 1.2 session-ticket keys are never rotated.
OutboundClientrelies on callers to keep CR/LF out of header values.accept4returningEMFILEonly sleeps; there is no reserve descriptor.- The main pool and the sync pool sizes are not configurable.
Area 3 — coverage gaps¶
- Not read:
src/net/listener.cpp, the canonical JSON serializer,federation_proxy.cpp, and the service launchers underpackaging/.
Area 4 — Federation inbound¶
Result: 12 findings confirmed: 1 critical, 5 high, 3 medium, 3 low. The spec citation for FED-2 was corrected. One sub-claim of FED-6 was refuted.
Prior fixes that hold:
- H-04, M-04 and M-05.
- Signature and hash checks on send_* and invite (#461, #462).
- L-06 for /backfill.
- ADR-0071: main re-verifies PDUs, invites and memberships that the worker relays.
EDU frames are not covered (FED-12).
FED-1 — send_join response events are not bound to the room being joined, and events from our own domain skip signature checks¶
- Severity: critical
- Attacker: A2 (any local user) working with A3 (a server they control)
- Verdict: confirmed; the orchestrator re-checked both halves in code
- Location:
src/homeserver/room_service.cpp:3138-3315(filter_verified_send_join_events; own-domain shortcut at:3244-3248):3016-3136(ingest_send_join_state; room ID taken from each event at:3078-3097):3944-4055(auth-chain loop),:4253-4255(background path)src/database/persistent_store.cpp:1772-1899(prepare/apply_store_event_with_state)- Spec: "Checks performed on receipt of a PDU … 2. Passes signature checks, otherwise
it is dropped." Project rule:
src/federation/AGENTS.mdrules 2 and 4. - Path:
- A local user calls
POST /join/!x:evil.example?server_name=evil.example. Theviaandserver_namevalues supplied by the client are accepted as join candidates (room_service.cpp:2973). - evil.example's
send_joinresponse includes, instateorauth_chain, anm.room.power_levelsevent whoseroom_idis L, a room hosted here that the user is in. The event gives the user power level 100 and names a sender on our domain. filter_verified_send_join_eventsaccepts it without any signature check, because the sender's domain is ours. The unit test attests/unit/test_federation_invite_join.cpp:1979-2012pins that behaviour. Events signed by evil.example for its own users withroom_id = Lalso pass.ingest_send_join_statestores each event under that event's ownroom_id. Nothing compares it with the room being joined.state_matches_eventcompares the event only with itself, and no auth rules run.prepare_store_event_with_stateissuesUPDATE current_statewhatever the event's status, so a stored "outlier" still replaces L's current state.compose_signed_eventbuilds and authorises every subsequent local event in L fromstore.state(room_service.cpp:1026,:1223-1258,:1497-1500). The forged power levels are now the rules for the room.- Impact:
- Any local user can make themselves admin of any local room.
- Any local user can forge membership for other users, including the victims of bans, via the background path.
- The forged state persists until a later inbound federation event triggers
recompute_current_statefor L. - Local events sent in that window name forged auth events, so remote servers reject them and the room diverges.
- Documentation:
docs/threat-model.md:496-521says a bad-faith resident server "degrades the joining server's view of the room rather than being able to inject forged state". That is not true. - Fix:
- Drop every
stateandauth_chainentry whoseroom_id(or, for v12m.room.create, whose derived room ID) is not the room being joined. - Never accept an own-domain event without a signature check. A resident server has no reason to send us events we authored that we do not already hold.
- Ingest the join snapshot without writing
current_statefor any other room. - Correct the threat model.
- Test: GIVEN local room L with power levels P, and a mock resident server whose
send_joinstateholds (a) an unsignedm.room.power_levelsfor L with an own-domain sender and (b) one signed by the mock server WHEN a local user joins!x:mockthrough it THEN L's current state and memberships are unchanged AND neither event is stored.
FED-2 — Federation read endpoints do not check that the requesting server is in the room¶
- Severity: high · Attacker: A3 · Verdict: confirmed; spec citation corrected
- Location:
src/federation/inbound_request.cpp:1250-1280,:1452-1544,:2329-2386src/homeserver/local_http_router.cpp:2182-2232src/federation/event_query.cpp:248-256,:297-558- Spec:
GET /state_idslists "403 | The requesting host is not in the room, or is excluded from the room viam.room.server_acl."/state,/event,/backfilland/get_missing_eventshave no 403 row. A membership rule for those is therefore a security requirement, not a spec MUST. The ACL MUST is implemented, except on/event, which is not in the spec's ACL list.- Path:
- The room-scoped providers take no origin. The only gate is the server ACL.
/stateand/state_idsfall back to the current state for an unknownevent_id./get_missing_eventsignoresearliest_eventsandlatest_eventsand has no cap onlimit. It returns every event in the room at or abovemin_depth, which is the full history in one request, scanned O(N) under the runtime lock./event/{id}returns any stored event.- Impact: any federating server that knows a room ID can dump the state, the member list and the entire history of any room hosted here, including private rooms. Encrypted rooms leak metadata and ciphertext.
- Fix:
- Pass the authenticated origin to every provider. Require the origin to have a
joined member, or history visibility that permits it, and answer 403
M_FORBIDDENotherwise. - Resolve
/eventthrough the event's room. - Implement
/get_missing_eventsas a walk fromlatest_events, with a cappedlimit. - Return 404 for an unknown
event_idinstead of falling back. - Test: GIVEN a room with only local members WHEN another server calls
/state,/state_ids,/event,/backfilland/get_missing_eventsfor it THEN each returns 403 and no room data.
FED-3 — The sender of an m.direct_to_device EDU is not bound to the sending server¶
- Severity: high · Attacker: A3 · Verdict: confirmed
- Location:
src/homeserver/local_http_router.cpp:1053-1107;src/federation/inbound_ingestion.cpp:290-293 - Detail:
- The EDU's
senderis stored as given. Typing, receipt and device-list EDUs do check the sender withuser_belongs_to_origin; this one does not. - Targets are not checked to be local users.
message_idis never read. The spec says it is "used for idempotence".- Impact:
- Any peer can deliver to-device messages (verification requests, key requests) that appear to come from any user on any server.
- Invented targets grow the queue without bound (compare CSAZ-10).
- Fix:
- Require
server_name(sender) == origin. - Deliver only to local users.
- Deduplicate on
(origin, message_id). - Cap fan-out per EDU.
- Test: GIVEN origin B WHEN it sends a to-device EDU with
sender=@x:Ato a local user THEN nothing is queued AND a replayedmessage_idfrom a valid sender is delivered once.
FED-4 — Forged X-Matrix requests lock a real peer out of federation until restart¶
- Severity: high · Attacker: A1 (no credential needed) · Verdict: confirmed
- Location:
src/federation/inbound_request.cpp:2263-2324,:2519,:2624,:2978;src/federation/security.cpp:313-320 - Path:
- The attacker sends a request naming
origin=peer.examplewith a real key ID and a garbage signature. check_inbound_request_signatureincrements the claimed origin'sconsecutive_failures.- After three such requests,
remote_trust_policyanswers 429 for that origin. It does so before the signature check, so it applies to every genuine request too. - The counter is reset only after an accepted request, which can no longer happen, and nothing decays it.
- The unit test at
tests/unit/test_federation_inbound_request.cpp:743-771asserts the lockout without modelling a third party. - Impact: three unauthenticated packets cut federation with any peer. This is a side effect of the earlier H-04 fix.
- Fix:
- Never charge a failed signature to the claimed origin's trust record; count it against the source address or the key-resolution budget instead.
- Decay origin-level backoff over time.
- Test: GIVEN a known peer P WHEN 10 bad-signature requests naming P arrive from another address THEN a correctly signed request from P is accepted.
FED-5 — An inbound /invite for a room hosted here overwrites a ban with a forged invite¶
- Severity: high · Attacker: A3 (with a banned local accomplice) · Verdict: confirmed; the URL event-ID sub-claim is mitigated by the worker relay
- Location:
src/homeserver/local_http_router.cpp:681-703,:2049-2154 - Path:
invite_handlerchecks the event shape, that the target is local and that the sender is on the origin. It runs no room auth rules.- It rewrites the target's membership row from
bantoinvite, and stores the forged event as the target's currentm.room.memberstate. - The banned user then joins. Local composition authorises against
store.state, seesinvite, and allows the join even in an invite-only room. - Impact: any remote server can undo a ban for a local user in a room hosted here.
- Fix:
- When the room is known locally, run the room's auth rules on the invite and refuse it on failure; never overwrite a ban.
- Keep remote invite events out of
current_state. - Bind the URL event ID to the computed ID on the direct path too.
- Test: GIVEN local user U banned in local room L WHEN a remote server sends a
validly signed invite for U into L THEN U stays
ban, L's state is unchanged, and the response is 403.
FED-7 — Device-list and signing-key EDUs write one row per local user, with no deduplication, under the global lock¶
- Severity: high (DoS) · Attacker: A3 · Verdict: confirmed
- Location:
src/homeserver/local_http_router.cpp:1473-1507src/database/persistent_store.cpp:2945-2968src/homeserver/worker_pool.cpp:829-840- Detail:
- Each EDU records a change for every local user, not only those who share a room with the subject.
- Rows are neither deduplicated nor pruned.
- Each insert is synchronous and runs under the runtime mutex.
- At the default 1200 EDUs per minute per origin, that is 1200 × (local users) rows a minute.
- Fix:
- Notify only users who share a room with the subject, and deduplicate on (observer, subject).
- Prune old rows.
- Weight the rate limit by fan-out.
- Do not hold the runtime mutex across per-user writes.
- Test: GIVEN N local users WHEN 100 identical device-list EDUs arrive for a remote user who shares no room THEN no rows are written.
FED-6 — send_join, send_leave and send_knock skip the spec's event validation, and the membership recorded comes from the endpoint¶
- Severity: medium · Attacker: A3 · Verdict: adjusted · Status: fixed in 0.12.19
- Refuted part: "no auth check".
membership_acceptorauthorises the event before writing it (local_http_router.cpp:1771-1857,:1866-1924). - Location:
src/federation/inbound_request.cpp:948-1097;src/homeserver/local_http_router.cpp:705-716,:1513-1627 - Spec: "The receiving server MUST apply certain validation before accepting the
event". The listed conditions include: the event type is not
m.room.member, the contentmembershipdoes not match the endpoint, the event sender is not a user ID on the origin server, and thestate_keyis not equal to thesender. Formake_*,userId"MUST be a user ID on the origin server". - Path:
- In a room with a knock join rule, a remote user signs a
membership: knockevent and PUTs it tosend_join. - The event passes auth as a knock.
membership_for_endpointrecordsjoin, and the user is added toLocalRoom.members. That list drives which servers receive fan-out, so the knocker's server now gets every PDU and EDU for the room.- Fix:
- Enforce the listed validations.
- Derive the membership from the event content and reject a mismatch with the endpoint.
- Apply the origin check on
make_*. - Test: GIVEN a knock room WHEN a knock event is PUT to
send_joinTHEN 400M_INVALID_PARAM, with no membership row and no fan-out destination.
FED-8 — Receipt EDUs bypass the server ACL¶
- Severity: medium (spec MUST) · Attacker: A3 · Verdict: confirmed
- Location:
src/federation/inbound_request.cpp:2388-2420,:2933-2952;src/homeserver/local_http_router.cpp:1033-1039,:1288-1381 - Spec: "For receipts (
m.receipt), all receipts for a particular room ID MUST be ignored if the sending server is denied access to the room identified by that ID." - Detail:
room_id_from_edu_contentlooks for a top-levelroom_idmember, but receipt content is keyed by room ID, so the ACL check never fires.- The receipt sink accepts any room with any local membership row, and does not require the receipt's user to be joined.
- Fix:
- Apply the ACL to each room key in the receipt content.
- Require the receipt's user to be joined.
- Test: GIVEN a room whose ACL denies evil.example WHEN evil.example sends a receipt for that room THEN nothing is stored.
FED-11 — PDUs for rooms with no local membership are stored without bound¶
- Severity: medium (low-medium) · Attacker: A3 · Verdict: adjusted
- Location:
src/homeserver/local_http_router.cpp:3564-3849 - Detail:
- No "room known here" gate exists. Such PDUs are stored as rejected, and they are never pruned.
- The spoofing risk is limited: create-event auth ties the room ID's domain to the sender for versions up to 11, and v12 room IDs are hashes.
- Fix: drop PDUs for rooms with no local membership or invite unless they answer an outstanding backfill.
- Test: GIVEN no local member of
!x:evilWHEN 50 PDUs for it arrive THEN none are stored.
FED-9 — A transaction whose first PDU has no room_id is processed in the main process, outside the worker sandbox¶
- Severity: low (defence in depth) · Attacker: A3 · Verdict: adjusted from medium
- Location:
src/homeserver/federation_request_routing.cpp:105-133,:179-187;src/homeserver/federation_proxy.cpp:142-150 - Fix: bypass the worker only when
pdusis present and empty. - Test: GIVEN a
/sendwhose first PDU lacksroom_id, followed by real PDUs WHEN it arrives THEN it is routed to the worker.
FED-10 — Resolving sender keys for relayed PDUs bypasses the key-resolution admission budget¶
- Severity: low · Attacker: A3 (authenticated) · Verdict: adjusted
- Location:
src/federation/inbound_request.cpp:2660-2726;src/federation/remote_key_cache.cpp:457-529 - Detail: each transaction can trigger up to 50 discovery and key fetches to domains of the attacker's choosing, with no negative cache.
- Fix: route this through the same admission budget and failure cache as the unknown-remote path.
- Test: GIVEN 50 unresolvable sender domains in one transaction WHEN it is processed THEN outbound fetches stay within the budget.
FED-12 — The main process trusts the origin of EDU frames relayed by the worker¶
- Severity: low (A5 hardening) · Verdict: found by the verifier
- Location:
src/homeserver/worker_pool.cpp:811-842 - Detail: ADR-0071 re-verification covers PDUs and memberships, not EDUs. A compromised worker can forge typing, receipt, device-list and to-device EDUs from any origin.
- Fix: bind the envelope origin to the origin main verified for the in-flight request.
- Test: GIVEN a worker relaying an EDU frame whose
origindiffers from the verified request origin WHEN main handles it THEN the EDU is rejected.
Area 4 — hardening notes¶
old_verify_keysare ignored, so events signed with a rotated-out key cannot be verified. This affects availability only.- Remote key IDs are not required to start with
ed25519:. - Sender-domain parsing exists in three differing implementations (
inbound_request.cpp,worker_pool.cpp:639,local_http_router.cpp:2836). Consolidate them to avoid differential parsing. server_name_is_validrequires a., and the X-Matrix scheme comparison is case-sensitive.- The
send_joinresponse never addsjoin_authorised_via_users_serverfor restricted rooms (already a recorded capability gap). event_authis unimplemented.
Area 4 — coverage gaps¶
- Read only in part:
outbound_membership.cpp,outbound_transaction.cpp,dispatch_worker.cpp, the worker event loop beyond provider wiring, and media federation download. - Unrouted, not audited:
/publicRooms,/timestamp_to_eventand/openid/userinfo.
Area 5 — Event engine (auth rules, state resolution, redaction, canonical JSON)¶
Result: 12 findings confirmed: 5 high, 4 medium, 3 low. None refuted; 5 were adjusted downward.
Why these matter: every finding is a divergence from the spec algorithm. When this server accepts an event that conformant servers reject, or resolves state differently, the room splits, and the local view of power levels and bans is wrong.
Prior fixes that hold:
- H-05, H-06, L-14 and L-15.
- H-07 holds for users and scalar keys, but not for the events map (EVT-11).
- #487 rule fixes: scalar power level 9.5, create validation, the @ state-key rule,
third-party-invite signatures, and v12 creators in users.
EVT-1 — Auth rule "join_authorised_via_users_server must be signed by that user's server" is not enforced¶
- Severity: high · Attacker: A3 · Verdict: confirmed
- Location:
src/events/authorization.cpp:1069-1099src/federation/inbound_request.cpp:1962-2020src/federation/security.cpp:260-284src/homeserver/room_service.cpp:3299- Spec:
rooms/v8.mdrule 4.2 (v9–v11 rule 4.2, v12 rule 5.2): "Ifcontenthas ajoin_authorised_via_users_serverproperty: 1. If the event is not validly signed by the homeserver of the user ID denoted by the key, reject." - Path:
- The restricted-join branch checks only that the named user is joined and has invite power. The authoriser receives no signature material.
- PDU verification checks only the sender server's signature.
- No code path verifies a signature keyed on
join_authorised_via_users_server. - Impact: any remote server can join its users to any restricted or
knock_restrictedroom by naming any joined member with invite power (the default invite level is 0). The room'sallowconditions are bypassed. - Fix:
- At PDU receipt and in
send_join, require a valid signature from the named user's server whenever the key is present, and fail closed if that server's key cannot be fetched. - Record the verified result on the event so that state-resolution re-authorisation uses it.
- Test: GIVEN a v10 restricted room with
@admin:usjoined WHEN a join from@evil:a3names@admin:usand is signed only by a3 THEN it is rejected AND the same join also signed byusis accepted.
EVT-2 — m.room.power_levels events are not held to events["m.room.power_levels"]¶
- Severity: high · Attacker: A3, or a local moderator · Verdict: confirmed (orchestrator re-checked)
- Location:
src/events/authorization.cpp:1343-1503; the return at:1490-1503usesstate_defaultonly. - Spec:
rooms/v12.mdrule 8 (v11 rule 7): "If the event type's required power level is greater than thesender's power level, reject."- The required level is "listed explicitly in the
eventssection or given by eitherstate_defaultorevents_default." - Path:
- In a default room, power-level events require level 100 and
state_defaultis 50. - A level-50 moderator sends a power-levels change within the bounds of rule 9 or 10,
such as lowering
kick, raisingusers_default, or promoting users below 50. - The code compares only against
state_defaultand allows it. - Existing test:
tests/conformance/test_event_auth_rules.cpp(scenario "Auth rules allow a power_levels change where both old and new scalar values are within the sender's power", around:3060-3090) asserts the non-conformant result and must be corrected. - Impact: moderators can rewrite the power-level map where the spec says only admins can. Conformant servers reject these events, so the room diverges.
- Fix: compute the required level (the
events[type]value, elsestate_default) before the power-levels-specific rules. - Test: GIVEN
events{"m.room.power_levels":100},state_default50 and a sender at 50 WHEN the sender changesusers_defaultTHEN the event is rejected.
EVT-3 — With three or more state groups, a conflicted key lands in both maps and the last group wins¶
- Severity: high · Attacker: A3 (controls
prev_events) and organic forks · Verdict: confirmed (orchestrator traced the loop) - Location:
src/events/state_resolution.cpp:929-955,:1503-1506 - Spec: "If a given key K is present in every Si with the same value V in each state map, then the pair (K, V) belongs to the unconflicted state map. Otherwise, V belongs to the conflicted state set."
- Path: for the groups
[K=A],[K=B],[K=A]: - The first group inserts A.
- The second group moves K to
conflicted. - The third group finds K absent from
unconflictedand re-inserts A. - Step 5 then overwrites the resolved value with the "unconflicted" one.
- The same happens for
A,B,BandA,B,C: whichever group comes last wins. - Impact: for keys present in every fork (power levels, join rules, each member), resolution is replaced by prev-event order. A ban in one of three forks can be dropped. No test uses more than two groups.
- Fix: build
key → set(event_id)with a per-key group count. A key is unconflicted only if it appears in every group with exactly one ID. - Test: GIVEN three groups with
member(@u)as join, ban, join WHEN partitioned THEN the key is only conflicted AND the result does not depend on group order.
EVT-4 — Reverse topological power ordering is a plain sort, not a topological sort¶
- Severity: high · Attacker: A3 · Verdict: confirmed (orchestrator re-checked)
- Location:
src/events/state_resolution.cpp:960-999 - Spec: "the lexicographically smallest topological ordering based on the DAG formed by auth events … sorting the events using Kahn's algorithm for topological sorting, and at each step selecting, among all the candidate vertices, the smallest vertex using the above comparison relation."
- Path:
std::stable_sortorders by power, then timestamp, then ID, ignoring auth-event dependencies between candidates.- Example: an admin's ban cites a moderator's earlier kick as an auth event. The ban is applied first, then the kick is re-applied over it, and the ban is lost.
- Fix: Kahn's algorithm over the auth DAG restricted to the set, choosing the minimum ready vertex by (power descending, timestamp ascending, event ID ascending).
- Test: GIVEN conflicted events where the higher-power event cites the lower-power one
in
auth_eventsWHEN they are ordered THEN the cited event comes first.
EVT-6 — Negative user power levels are replaced by users_default¶
- Severity: high · Attacker: A2 or A3 (a muted user) · Verdict: confirmed (orchestrator re-checked)
- Location:
src/events/authorization.cpp:148-154(-1 used as the "absent" value),:831,:1442-1446,:1462-1466 - Spec: "If a
user_idis in theuserslist, then thatuser_idhas the associated power level";users_defaultapplies to users "not mentioned in theuserskey". The allowed range is[-(2**53)+1, (2**53)-1]. - Detail:
- A user set to -1 (the usual client "mute") gets
users_default, so their messages passevents_default0. - If
users_defaultis higher, a muted user is effectively promoted. - The same value feeds the kick, ban, invite and power-level target rules.
- Fix: return
std::optional<int64_t>from theuserslookup and fall back only when the user is absent. - Test: GIVEN
users{"@u":-1}andevents_default0 WHEN @u sends a message THEN it is denied.
EVT-5 — Mainline ordering sees only the events inside the submitted state groups¶
- Severity: medium · Attacker: A3 · Verdict: adjusted from high
- Location:
src/events/state_resolution.cpp:106-138,:1246,:1489;src/homeserver/state_bookkeeping.cpp:186,:312 - Spec: "Repeatedly fetch Pi+1, the
m.room.power_levelsevent in theauth_eventsof Pi". - Detail:
power_levels_auth_ancestoronly searches the state-group index and never usesevent_lookup, so the mainline collapses to the current power-levels event.- Events based on older power levels are then ordered by timestamp, which the sender controls.
- Iterative auth checks still gate each event, so the effect is divergence, not bypass.
- Fix: walk the mainline through the
AuthChainEventSource, failing closed if an event is missing. - Test: GIVEN a power-levels chain P2→P1→P0 where only P2 is in the groups WHEN the mainline is ordered THEN events based on P0 and P1 get positions 2 and 1.
EVT-7 — v11 and v12 creator logic reads content.creator, which those versions removed¶
- Severity: medium · Attacker: A3 / organic · Verdict: adjusted from high
- Location:
src/events/authorization.cpp:739-745,:1010-1017,:1292-1299;src/homeserver/room_service.cpp:2469 - Spec:
rooms/v11.md: "Thecontentof am.room.createevent no longer has acreatorproperty"; "If the only previous event is anm.room.createand thestate_keyis the sender of them.room.create, allow." - Detail:
- For v11 and v12 rooms created elsewhere, the creator's first join and first power-levels event fail when re-authorised locally (state-resolution auth difference, backfill).
- Locally created rooms always include
creator, which is why the conformance fixtures (test_event_auth_rules.cpp:17-63) do not catch this. - Fix:
- For v11+, derive the creator from the create event's sender, plus
additional_creatorsin v12. - Require
prev_events == [create]for the bootstrap join. - Stop writing
creatorfor v11+. - Test: GIVEN a v11 create event without
creatorWHEN the creator's join withprev_events=[create]is authorised THEN it is allowed.
EVT-8 — In v12 power ordering, room creators rank at the default power level¶
- Severity: medium · Attacker: A3 · Verdict: confirmed
- Location:
src/events/state_resolution.cpp:558-604,:624-633 - Detail:
- v12 events never list the create event in
auth_events, socontext->createis null. user_is_room_creatorreturns false, so a creator's actions are ordered as if the creator had default power instead of infinite power.- The auth check itself derives the create event from the room ID correctly.
- Fix: pass the create event derived from the room ID into
power_level_from_eventfor v12. - Test: GIVEN a v12 room where a creator's ban and an admin's power-levels edit conflict WHEN ordered THEN the creator's event is first.
EVT-9 — The conflicted-state-subgraph walk enumerates paths without memoisation and fails closed¶
- Severity: medium (availability) · Attacker: A3, and large legitimate v12 rooms · Verdict: confirmed
- Location:
src/events/state_resolution.cpp:424-488;include/merovingian/events/limits.hpp:63 - Detail:
- A recursive DFS with no visited set shares one 20 000-visit budget across all start nodes.
- When the budget runs out, resolution fails,
compute_state_beforereturns not-ok, andrecompute_current_stateleaves state unchanged. - A v12 room with a persistent fork can therefore stop applying bans and power-level changes.
- Fix: compute the subgraph as the intersection of "reachable from a conflicted event" and "reaches a conflicted event" with memoised DAG traversal, and cap distinct events only.
- Test: GIVEN a 30-rung power-levels ladder and 100 conflicted member events WHEN v12 resolution runs THEN it resolves within the distinct-event cap.
EVT-11 — String-valued levels in the events map are ignored in v3–v9¶
- Severity: low (upper end) · Attacker: A3 · Verdict: adjusted from medium
- Location:
src/events/authorization.cpp:1538,:1573 - Spec:
rooms/v9.md: string encoding "includes the nested values within theevents,notificationsandusersproperties." - Fix: use
power_level_member(..., allow_strings)at both sites. - Test: GIVEN a v9 room with
events{"m.room.topic":"100"}WHEN a user at level 50 sets the topic THEN it is denied.
EVT-10 — A non-object content.third_party_invite is treated as absent¶
- Severity: low · Attacker: A3 with invite power · Verdict: adjusted from medium
- Location:
src/events/authorization.cpp:1157-1164 - Spec: "If
content.third_party_invitedoes not have asignedproperty, reject." - Fix: enter the third-party branch whenever the key is present.
- Test: GIVEN a sender with invite power WHEN an invite carries
third_party_invite: "x"THEN it is denied.
EVT-12 — Smaller rule divergences¶
- Severity: low · Verdict: (a) and (c) confirmed, (b) documented, (d) not verified
- (a) Missing v3–v5
m.room.aliasesrule.rooms/v3.md: "If sender's domain doesn't matchstate_key, reject. 3. Otherwise, allow." It is absent fromauthorization.cpp. - (b) A creator with no member event is treated as joined.
src/events/authorization.cpp:1292-1299. This deviation is documented indocs/event-engine.md:119. - (c) Rejected auth events are not refused. Rule "If there are entries which were
themselves rejected under the checks performed on receipt of a PDU, reject" is not
checked in
validate_auth_events_selection(src/homeserver/local_http_router.cpp:~365-400) or in the resolver'sfind_own_auth_event(src/events/state_resolution.cpp:646-681). - (d) v3–v5 are stricter than the spec on scalar power-level values. This fails closed.
- Also noted by the verifier: rule 4.2 applies to any membership that carries the key, but the code reads the key only in the restricted-join branch.
Area 5 — hardening notes¶
resolve_state()(state_resolution.cpp:849-905) is a v1-style depth-ordered resolver. It is exported but unused. Remove it so it cannot be wired to a v3+ room.redact_eventcopiescontentunredacted whentypeis not a string (redaction.cpp:180). Return an error instead.parse_losslessis strict for every room version.rooms/v6.mdsays v3–v5 events "might not be fully compliant", so one float in a v3–v5 room blocks everything that descends from it.StateKeyHashcombines unseededstd::hashvalues with XOR. It is bounded by the 10 000-key cap.
Area 5 — coverage gaps¶
- Not read closely:
canonicaljson/value.cpp, and the yyjson handling of lone\usurrogates. - Spec sections "Checks performed on receipt of a PDU" and "Soft failure" were compared only as far as the callers audited in Area 4.
Area 6 — Outbound requests and SSRF¶
Result: 8 findings confirmed: 1 high, 3 medium, 4 low. Two of them came from the verifier. The verifier corrected the mechanism of OUT-1, reproducing libcurl's and glibc's handling in an isolated scratch environment.
Prior fixes and controls that hold:
- M-06: the literal-discovery opt-in has no production caller.
- Redirects are refused (FOLLOWLOCATION=0).
- Outbound requests use https only.
- Header and body caps.
- The push gateway URL, path and enabled gates.
- The identity-server allowlist: clients cannot supply an arbitrary base URL.
- /preview_url is not implemented, so it is no SSRF surface.
OUT-7 — security.media.remote_fetch_enabled=false does not stop remote media fetches¶
- Severity: high
- Attacker: A1
- Verdict: found by the verifier; confirmed by the orchestrator. The verifier rated it medium and the orchestrator raised it (see Impact).
- Location:
src/homeserver/media_service.cpp:705-746(fetch_remote_media_live),:1008-1023,:1063src/media/repository.cpp:807,:827include/merovingian/config/config.hpp:422- Rule:
docs/user-manual.md:899describes the flag as the "Opt-in for live remote media fetching". It defaults to off. - Path:
GET /_matrix/media/v3/download/{server}/{id}is served before authentication.- It reaches
fetch_remote_media_live. That function runs server discovery, the federation media request (up tomax_upload_bytes + 4096, 120 s total) and the legacy fallback. - The flag is checked only in
media::fetch_remote_media, after the bytes have arrived, and the result is then discarded. - Nothing under
src/homeserver/reads the flag. - Impact:
- On the default configuration, any unauthenticated caller can make the server connect to an arbitrary server and download up to about 50 MiB.
- Each request holds a main-pool worker for up to about 150 s, so 8 such requests stall the server (compare HTTP-1).
- It makes OUT-1 and OUT-2 reachable on the default configuration.
- Fix:
- Check the flag at the top of
fetch_remote_media_liveand in the thumbnail path, before any discovery. - Run remote fetches outside the main pool, with an in-flight cap.
- Test: GIVEN
remote_fetch_enabled=falseWHEN an unauthenticated client requests/_matrix/media/v3/download/remote.example/abcTHEN no discovery or outbound call occurs and the response is 404.
OUT-1 — Server names containing @, ? or # defeat address pinning¶
- Severity: medium · Attacker: A1 · Verdict: adjusted (mechanism corrected)
- Location:
src/federation/security.cpp:121-125(weakserver_name_is_valid)src/federation/server_discovery.cpp:294-304,:530-649src/http/outbound_client.cpp:187-223,:410-442,:713-729src/federation/outbound_transaction.cpp:45-63src/federation/remote_key_cache.cpp:213-226- Spec:
appendices.mdServer Name:dns-char = DIGIT / ALPHA / "-" / ".". - Project rule:
src/http/AGENTS.mdsays pinning throughCURLOPT_RESOLVEkeeps the SSRF policy "the single source of truth". - Path:
- Three entry points accept such a name, all before authentication:
- the X-Matrix
origin="x@evil.example"header, which triggers key resolution before the signature is checked; GET /directory/room/%23a%3Ax%40evil.example;- the media download path (with OUT-7).
- the X-Matrix
federation::server_name_is_validaccepts@ ? # \ %. The strictauth::server_name_is_valid(src/auth/identity.cpp:252) is not used here.- glibc
getaddrinforefuses these names, so the direct and.well-knownsteps fail.res_querydoes send_matrix-fed._tcp.x@evil.example, so SRV is the working vector. - The attacker's DNS answers SRV with a public target and port P. Only that target is SSRF-checked and pinned.
- The URL becomes
https://x@evil.example:P/…, and the pin key isx@evil.example:P. - libcurl treats
xas userinfo andevil.exampleas the host, so the pin never matches. curl then resolves the name through system DNS, which the attacker answers with an internal address. - No connect-time check exists: there is no
OPENSOCKETFUNCTION, and seccomp permitsconnect. - Impact:
- A pre-authentication blind TCP connect plus a TLS ClientHello to an attacker-chosen internal host and port.
- The TLS name check fails, so no data comes back. It still works as an internal port-scan and timing oracle.
- Cloud metadata endpoints are plain HTTP and cannot be reached.
- Fix:
- Apply the strict server-name grammar at every entry point and in
discover_server. - In
OutboundClient, derive the pin host and port with libcurl's URL API (CURLU), and refuse any authority containing@ ? # \. - Add a connect-time address check (
CURLOPT_OPENSOCKETFUNCTION) as defence in depth. - Test:
- GIVEN an
OutboundRequesttohttps://x@example.org/pWHEN it is performed THEN it returnsinvalid_urland no socket opens. - GIVEN X-Matrix
origin="a@b.example"THEN no discovery occurs.
OUT-2 — A remote media redirect's Location is validated by one parser and connected by another¶
- Severity: medium · Attacker: A7 (reachable on the default configuration through OUT-7) · Verdict: confirmed
- Location:
src/homeserver/media_service.cpp:153-208,:660-668,:924-944;src/http/outbound_client.cpp:197-198 - Path:
parse_https_authoritystops at/,?or#, and SSRF-checks and pins the host it finds.- The raw
Locationis then passed toOutboundClient, whose parser stops only at/. - For
https://rebind.example#x, the pin key never matches, and curl re-resolves the host. That DNS-rebinding window reaches internal addresses on port 443. - Fix: parse once, then build the request from the parsed parts; OUT-1's
OutboundClientfix also closes this. - Test: GIVEN a redirect to
https://a.example#xwhose second lookup returns 127.0.0.1 WHEN the fetch runs THEN no connection reaches 127.0.0.1.
OUT-4 — Remote media is re-fetched and stored on every request, with no cache and no eviction¶
- Severity: medium (when
remote_fetch_enabled=true) · Attacker: A1 · Verdict: confirmed - Location:
src/homeserver/media_service.cpp:479,:1017-1023;src/media/repository.cpp:289-302,:540-560;include/merovingian/config/config.hpp:396-404 - Detail:
- There is no lookup by (origin, mediaId), so each request fetches again and appends a record.
- The default quotas are unlimited, so memory grows until the process runs out.
- With quotas configured, remote media fills them and local uploads are refused with 507.
- Fix:
- Give remote media its own cache keyed by (origin, mediaId), with LRU or TTL eviction and a budget separate from local uploads.
- Rate-limit remote fetches per client and per origin.
- Test: GIVEN remote fetch enabled WHEN the same remote media is requested 1000 times THEN at most one record exists and the origin sees one fetch within the TTL.
OUT-3 — One user's stalled push gateways consume the global push-delivery cap¶
- Severity: low (push is off by default; drop-at-cap is documented in ADR-0028 and ADR-0029) · Attacker: A2 · Verdict: adjusted from medium
- Location:
src/homeserver/room_service.cpp:5621-5636,:5716-5779;src/homeserver/client_server.cpp:10910-10960 - Detail:
- The number of pushers a user can register is uncapped.
- Each delivery calls up to 10 pushers in sequence, each with a 30 s timeout.
- 128 such tasks exhaust the global cap, after which every user's pushes are dropped.
- Fix:
- Give each user an in-flight cap.
- Break the circuit to a stalled gateway host.
- Cap the number of pushers per user.
- Test: GIVEN one user with 10 stalled gateways WHEN another user's event is sent THEN that delivery is still dispatched.
OUT-5 — libcurl honours proxy environment variables, which bypass pinning¶
- Severity: low (needs control of the operator's environment) · Verdict: confirmed
- Location:
src/http/outbound_client.cpp:410-442 - Fix: set
CURLOPT_PROXY=""andCURLOPT_NOPROXY="*"explicitly, and fail closed if either cannot be set. - Test: GIVEN
https_proxypointing at a local listener WHEN a pinned request runs THEN the listener receives no connection.
OUT-6 — The server-discovery cache is unbounded¶
- Severity: low · Attacker: A1 · Verdict: confirmed
- Location:
src/federation/cached_server_discovery.cpp:24-50 - Fix: cap the cache size, sweep expired entries, and never cache names that fail the strict grammar.
- Test: GIVEN a cap of N WHEN N+1 names are discovered THEN at most N remain.
OUT-8 — security.federation.deny_ip_ranges is validated but never applied¶
- Severity: low (the documentation over-promises) · Verdict: found by the verifier, confirmed by the orchestrator
- Location:
src/config/config.cpp:962-969;src/federation/runtime_federation.cpp:64;src/federation/security.cpp:40-160(hard-coded ranges);docs/threat-model.md:723,:786 - Fix: apply the configured ranges in
address_set_allowed, or remove the setting and correct the threat model. - Test: GIVEN
deny_ip_rangescontaining 203.0.113.0/24 WHEN a destination resolves into it THEN discovery is refused.
Area 6 — hardening notes¶
- Ranges the built-in deny list does not block:
- IPv4: 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 and 192.88.99.0/24.
- IPv6:
::/96,fec0::/10,2002::/16(6to4 with an embedded private address), Teredo2001::/32and64:ff9b:1::/48. - Treat IPv4-embedded IPv6 forms by their embedded address.
- An IPv6-literal server name cannot federate, because
parse_request_host_portsplits at the first:. - Inbound requests from servers delegated to port 8008 are refused
(
server_discovery.cpp:204). .well-knownredirects are refused, and the cache TTL is a fixed 60 s. The spec says redirects "should be followed" and recommends a 24 h TTL.allow_remote=falseis ignored on client download and thumbnail requests.- The
get_missing_eventsoutbound path does not percent-encoderoom_id(local_http_router.cpp:2680).
Area 6 — coverage gaps¶
- Read only in part: the worker IPC path for outbound requests, and
dispatch_worker.cppretry logic. - Not verified: resolver behaviour on the BSDs for names containing
@. The direct-path variant of OUT-1 may also work there.
Area 7 — Cryptography, key management and worker IPC¶
Result: 6 findings confirmed: 1 high, 1 medium, 4 low. Refuted: half of CRY-5, and
the claim that sodium_init is unchecked.
What holds:
- The IPC handshake and framing. The authentication MAC binds a role byte and both
ephemeral public keys, and is verified before key derivation.
- Keys are separated per direction with crypto_kx. Secretstream provides ordering and
replay protection, and accepts TAG_MESSAGE only.
- The IPC socket is a socketpair passed with CLOEXEC.
- No non-CSPRNG randomness is used anywhere.
- sodium_init is called lazily and checked, failing closed, on every use.
- Prior fixes L-07, M-07, H-05, and the 0.12.5 findings 1, 2, 3, 5, 20 and 21, still
hold.
- N1 (the worker holds no master-key file) and #419, #432 and #433 still hold.
CRY-1 — The main process signs arbitrary bytes with any held key when the federation worker asks¶
- Severity: high · Attacker: A5 · Verdict: confirmed (orchestrator re-checked the handler)
- Location:
src/homeserver/worker_pool.cpp:1242-1262src/crypto/runtime_multikey_ed25519_provider.cpp:24-41src/ipc/ipc_ed25519_provider.cpp:111-125src/federation_worker/worker_event_loop.cpp:599,:607- Rule:
src/federation_worker/AGENTS.mdrule 1.- ADR-0015: "A compromised worker can request signatures but cannot exfiltrate the signing key. That is the entire point of the split".
docs/threat-model.md:372-379presents the split as the fix for "a compromised worker could forge federation signatures".- Path:
- A compromised worker sends
{"type":"sign_request","key_id":"ed25519:…","canonical_json":"<any bytes>"}. - Main's handler signs whatever it is given with whichever held key is named, including retired keys. There is no domain tag, no allowlist of request shapes, no key pinning and no binding to an operation main started. The only limit is the per-channel in-flight cap of 256.
- The handler also holds the global
runtime_.mutexwhile it signs. Frames can be about 87 MiB (see CRY-2), so it doubles as a lock-hold DoS. - Production need: none.
- Outbound X-Matrix requests are signed in main (
room_service.cpp:1725-1746: "the Ed25519 secret never crosses the IPC boundary"), and so are invites. /_matrix/key/v2/serveris served by main.- The worker installs the IPC provider only so that it can skip loading the key
(
runtime.cpp:729-733). - Impact:
- A compromised worker, the process that is most exposed, can mint valid signatures as this server: forged PDUs "from" any local user, forged X-Matrix requests, and forged key responses.
- It still has outbound network access, so it can deliver them to any peer.
- ADR-0071's re-verification does not help, because the forgeries go to third parties, not through main.
- The documentation understates this. The oracle appears once, as an ADR-0015 consequence, and rests on a "must produce signatures" premise the code contradicts.
- Fix:
- Remove the
sign_requestframe,IpcEd25519Providerand the worker'ssigning_override, and give the worker a provider that refuses every request. - If worker-originated signing is ever needed, accept only typed requests. Main then builds the payload itself from validated fields, pins the active key, and refuses anything shaped like an event.
- Correct ADR-0015 with a superseding ADR, and correct the threat model.
- Test: GIVEN a connected worker channel WHEN it sends
sign_requestcontaining a serialised PDU or arbitrary bytes THEN the response is an error with no signature AND the runtime lock is not taken.
CRY-2 — A compromised worker can exhaust main's memory through the IPC dispatch queue¶
- Severity: medium · Attacker: A5 · Verdict: confirmed
- Location:
src/ipc/channel.cpp:57-65,:157-171,:228-231,:397-490include/merovingian/ipc/channel.hpp:211src/homeserver/worker_pool.cpp:436-450,:1119-1126- Rule: ADR-0065 says a flooding worker "can only occupy
ipc_max_in_flight_requestsslots per channel, capping memory regardless of how fast it sends frames". That is not true of the queue. - Path:
- The reader thread decrypts and parses each frame, then appends it to an unbounded
std::deque. - The in-flight cap applies only after a frame is dequeued.
- The per-frame cap is sized for main-to-worker
send_joinresponses (about 87 MiB at the defaultjoin_response_max_size), but it is applied in both directions. raw_send_exactblocks, with no send timeout. A worker that stops reading therefore stalls the dispatcher and handler threads while the reader keeps queueing.handler_pool_is shared across shards.- Impact:
- The sandboxed process can OOM-kill the process it is sandboxed from.
- Even without the queue, 256 in-flight frames of 87 MiB allow about 22 GiB to be retained.
- Fix:
- Bound the queue by count and by bytes, and apply backpressure or mark the channel unhealthy when it is full.
- Add a send timeout.
- Use a small frame cap for worker-to-main requests.
- Make the in-flight cap byte-aware.
- Test: GIVEN a peer that sends maximum-size frames and never reads WHEN the queue reaches its cap THEN the channel is marked unhealthy or reading stops, and resident memory stays bounded.
CRY-3 — The master key's length is not enforced¶
- Severity: low (needs both a low-entropy key from the operator and a database leak) · Verdict: adjusted from medium
- Location:
src/crypto/master_key.cpp:31-100;src/crypto/secret_box.cpp:67-82;src/crypto/token_key.cpp:72-88;src/crypto/ipc_auth_key.cpp:67-82;tests/unit/test_crypto.cpp:816-834 - Detail:
- Any length from 1 to 4096 bytes is accepted. The existing test accepts 24 bytes.
docs/user-manual.md:614and:2139describe a 32-byte key.- Subkeys are a single BLAKE2b keyed by a public label, with no stretching. A passphrase-style key therefore lets someone holding the database brute-force the encrypted signing secret offline.
- Fix:
- Require at least 32 bytes and refuse an all-zero file.
- Derive subkeys with
crypto_kdf_derive_from_key. - Update the test.
- Test: GIVEN a master-key file of 24 bytes WHEN the server starts THEN startup is refused with a message naming the 32-byte minimum.
CRY-4 — SecretBuffer page locking is unreliable, and derived keys are not locked¶
- Severity: low · Attacker: A6 (swap or raw memory) · Verdict: confirmed
- Location:
src/core/secret_buffer.cpp:19-42,:76-95src/crypto/master_key.cpp:53,:85,:138-141src/homeserver/auth_service.cpp:121-149- Detail:
- The backing store is a
std::vectoron the ordinary heap, locked withsodium_mlock. munlockis page-granular and not reference-counted. Destroying one buffer, such as the 4096-byte scratch buffer inload_master_key_material, therefore unlocks neighbouring secrets on the same page, while theiris_locked()still reports true.- The secret-box, token-HMAC and IPC-auth keys live in plain
std::arraycaches. sodium_mallocis not used anywhere.- Fix: back
SecretBufferand the derived-key caches withsodium_malloc. - Test: GIVEN two live
SecretBuffers on one page WHEN one is destroyed THEN the other stays locked.
CRY-5 — Registration validation secrets are compared with !=¶
- Severity: low (a project-rule violation; not practically exploitable) ·
Verdict: adjusted. The
mutual_roomshalf is refuted: the compared value only selects an offset the caller could reach by paging. - Location:
src/homeserver/client_server.cpp:1814,:1840 - Rule:
docs/security-coding-rules.md:205: "Always use constant-time comparison for secrets". - Fix: use
crypto::constant_time_equal(hash, then compare), or key sessions by a hash ofclient_secret. - Test: GIVEN a session WHEN a
client_secretis presented THEN it is compared with the constant-time helper.
CRY-6 — Token issuance falls back to an unkeyed hash if the master key becomes unavailable¶
- Severity: low · Verdict: confirmed (hardening)
- Location:
src/homeserver/auth_service.cpp:244-277;docs/crypto-boundary.md:218-220 - Detail:
- When the key derivation fails at runtime, for example because the key file was replaced or has become unreadable, newly issued tokens are silently hashed without a key.
docs/crypto-boundary.mdstill describes a no-master-key mode, which no longer exists since 0.12.5.- Fix: fail issuance with 503 instead, and correct the document.
- Test: GIVEN the master key becomes unreadable after start WHEN a token is issued THEN issuance fails and no unkeyed digest is stored.
Area 7 — hardening notes¶
- The mutual-rooms pagination key is derived from the Ed25519 signing secret
(
runtime.cpp:773-784), which crosses key purposes. Its MAC input also concatenates fields with no separator (client_server.cpp:3216-3220). - Retired signing-key secrets stay in the database indefinitely.
- A channel that becomes unhealthy while its worker process stays alive is never respawned, so that shard answers 503 until the worker exits.
Area 7 — coverage gaps¶
- Not audited: the frame serialisers in
federation_ipc_frames.cpp, and TLS private-key handling. - The thumbnail-worker pipe framing was skimmed only; see Area 9.
Area 8 — Process isolation and platform hardening¶
Result: 8 findings confirmed: 1 high, 2 medium, 5 low. Four were adjusted downward. The verifier demonstrated the kernel semantics behind ISO-1 with a scratch program on this host.
Prior fixes that hold:
- #319 (no execve in the worker profile) and #428.
- ADR-0042 (minimal worker environment).
- 0.12.5 findings 21 and 22.
- ADR-0062 parts 1–3 as written.
- ADR-0071.
- The M-08 decoder profile.
- The BPF architecture guard, which also defeats the x32 bypass.
- KILL_PROCESS as the default seccomp action.
- No ptrace, bpf, userfaultfd, keyctl, mount or unshare in any allowlist.
The thumbnail decoder is not affected by ISO-1: it creates no threads and installs its filter first.
ISO-1 — The worker's seccomp and Landlock sandbox does not cover the logger threads, which start before it is applied¶
- Severity: high (federation worker); low (main process, whose own filter allows
execve) - Attacker: A5
- Verdict: confirmed; re-checked by the orchestrator and demonstrated by the verifier
- Location:
include/merovingian/observability/logger.hpp:178-182,:334-338src/federation_worker/main.cpp:134,:183,:213src/platform/seccomp_hardening.cpp:790,:818,:829-853src/platform/landlock_hardening.cpp:273-286- Rule:
src/platform/AGENTS.mdrule 1.- ADR-0062 part 3: "A compromised worker … can no longer open any of those directly off disk".
- The code comments say hardening runs "before … starts threads" (
main.cpp:205-206,runtime_hardening.cpp:596). - Path:
- The worker's first
LOG_INFO(main.cpp:134) constructsSingleLog. Its constructor always starts two writer threads. - Landlock (
:183) and the worker seccomp filter (:213) are then installed with flags 0. Both apply to the calling thread only; noSECCOMP_FILTER_FLAG_TSYNCis used anywhere in the repository. - The two logger threads therefore keep only the filter inherited from main, which
allows
execve,open,socketandconnect. They have no Landlock ruleset and no worker filter. - With code execution in any worker thread, the attacker installs a signal handler
(
rt_sigactionis allowed) and aims it at a logger thread withtgkill(also allowed). - The handler then runs unconfined. It can read the master key, the database URI
file and TLS keys, or
execvea shell. - The self-check reads
/proc/self/status, which reports the thread-group leader only, so it cannot see this. - No test covers multi-threaded enforcement.
- Impact:
- The federation worker's documented containment can be bypassed with syscalls its own allowlist permits.
- Stealing the master key and the database's encrypted signing secret yields the server's signing key.
- Fix:
- Make sure no thread exists before hardening: start the logger's writers lazily or explicitly, and move the worker's first log line after seccomp.
- Install seccomp with
SECCOMP_FILTER_FLAG_TSYNC, and treat failure as fatal. - Landlock has no equivalent on older kernels, so the ordering is what matters there.
- Extend the self-check to every
/proc/self/task/<tid>/status. - Test: GIVEN a process that has already logged WHEN the worker hardening sequence is
applied THEN every task reports
Seccomp: 2andNoNewPrivs: 1AND a pre-existing thread cannotexecveor open the master-key path.
ISO-2 — A compromised worker can kill, freeze or resource-starve the main process¶
- Severity: medium · Attacker: A5 · Verdict: confirmed
- Location:
src/platform/seccomp_hardening.cpp:589-591,:620src/homeserver/worker_supervisor.cpp:425include/merovingian/platform/landlock_hardening.hpp:133- Detail:
kill,tkill,tgkillandprlimit64are allowed with no argument filtering.- The worker runs as the same uid as main.
- Landlock is capped at ABI 3, so signal scoping (ABI 6) is never used.
kill(getppid(), SIGSTOP)freezes main without a crash, so systemd'sRestart=on-failurenever fires.prlimit64can lower main'sRLIMIT_NOFILEorRLIMIT_AS.docs/threat-model.mddoes not mention this path.- Fix:
- Run the worker under a distinct uid.
- Otherwise, argument-filter these syscalls to the worker's own process, remove
killandtkill, and useLANDLOCK_SCOPE_SIGNALon ABI 6 and later. - Test: GIVEN the worker filter WHEN it calls
kill(getppid(), 0)orprlimit64(getppid(), …)THEN the call is denied.
ISO-3 — The worker supervisor stops respawning after a single failed restart¶
- Severity: medium (availability) · Attacker: A3 can induce the crash; the spawn failure is environmental · Verdict: confirmed
- Location:
src/homeserver/worker_supervisor.cpp:461-543 - Path:
- After a crash,
worker_pid_is set to -1. - If
spawn_and_connectthen throws (EAGAIN,ENOMEM,EMFILE, or the binary is missing during an upgrade), the exception is only logged. - The next loop iteration calls
waitpid(-1, WNOHANG). Depending on what else is running, that:- exits the supervisor thread on
ECHILD, permanently; - returns 0 forever, so no respawn is ever attempted; or
- reaps a sibling shard's child, whose own supervisor then exits.
- exits the supervisor thread on
- Impact: a shard stays at 503 until the server restarts.
docs/threat-model.md:469promises automatic restarts with exponential back-off. - Fix:
- Never call
waitpid(-1). - Treat
worker_pid_ <= 0as "spawn needed" and retry with back-off. - Reset the back-off only after a minimum uptime.
- Test:
- GIVEN a worker exits and the next spawn fails once WHEN the condition clears THEN the
supervisor respawns AND
healthy()becomes true. - GIVEN two shards WHEN shard A's respawn fails THEN shard B's child is not reaped.
ISO-4 — On SQLite the worker can write to main's database¶
- Severity: low (SQLite is documented for small installations) · Attacker: A5 · Verdict: adjusted from medium
- Location:
src/database/sqlite_store.cpp:148;src/platform/landlock_hardening.cpp:329-330;docs/threat-model.md:429-442 - Detail:
- Directory read-write access is documented as necessary for WAL.
- The residual-risk text says only "readable". A compromised worker can also change main's system of record, for example admin flags or room state.
- Fix: correct the wording. Better, require PostgreSQL when the worker is enabled, or
open the database
SQLITE_OPEN_READONLYwith a separate-shmgrant. - Test: GIVEN SQLite and the worker under Landlock WHEN it opens the database for writing THEN the open fails.
ISO-5 — The thumbnail decoder inherits main's environment and stderr, and its output is not checked¶
- Severity: low · Verdict: adjusted
- Location:
src/media/thumbnailer.cpp:462,:527-532;src/core/file_descriptor.cpp:222-224 - Fix:
- Use the minimal worker environment.
- Point fd 2 at
/dev/null. - Validate the PNG signature and IHDR dimensions in the parent.
- Test: GIVEN a decoder response that is not a PNG matching the reported size WHEN main processes it THEN it returns 502.
ISO-6 — No umask is set, and the registration token is written before it is chmoded¶
- Severity: low · Attacker: A6 · Verdict: adjusted
- Location:
src/database/sqlite_store.cpp:139-148include/merovingian/observability/logger.hpp:270(the log file follows symlinks and is notO_CLOEXEC)packaging/deb/postinst:16-21packaging/systemd/merovingian.service- Detail: the packaged
0750directories mask this. Unpackaged installs get0755and0644files. - Fix:
- Call
umask(0077)early in each executable, and setUMask=0077in the unit. - Create the database and log file with
O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC, mode 0600. - In the package scripts, write the token under
umask 077. - Test: GIVEN a fresh
sqlite_pathin a new directory WHEN the store opens THEN the file is 0600 and the directory is 0700.
ISO-7 — Secret files are checked with lstat and then opened again by path, with no owner check¶
- Severity: low · Attacker: A6 (only with a misconfigured directory) · Verdict: adjusted
- Location:
src/platform/file_metadata.cpp:56,:81-93;src/main.cpp:196-211;src/crypto/master_key.cpp:37-40;src/homeserver/worker_pool.cpp:1016 - Fix: use the pattern
auth_service.cpp:563already uses:open(O_NOFOLLOW|O_CLOEXEC), thenfstat, then requirest_uid == geteuid()and mode 0400, then read from the same descriptor. - Test: GIVEN a secret path that is a symlink, or a file owned by another uid WHEN it is loaded THEN loading fails.
ISO-8 — The systemd unit omits address-family, umask and kernel-protection directives, and the worker may open any socket family¶
- Severity: low · Verdict: confirmed
- Location:
packaging/systemd/merovingian.service;src/platform/seccomp_hardening.cpp:491 - Fix:
- Add
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX,UMask=0077,ProtectKernelTunables=,ProtectKernelModules=,ProtectKernelLogs=,ProtectControlGroups=andRestrictNamespaces=. - Argument-filter
socket()domains in the worker profile. - Document that the worker's PostgreSQL role must use password authentication, not
peer authentication, because Landlock does not govern AF_UNIX
connect. - Test: GIVEN the worker filter WHEN it calls
socket(AF_ALG, …)THEN it is killed.
Area 8 — hardening notes¶
posix_spawnof the federation worker does not useclosefrom, and relies on every descriptor beingCLOEXEC. The logofstreamis notCLOEXEC.- The restart back-off resets to 1 s immediately after a successful spawn, so it never grows for a crash loop.
ioctlandfcntlare unrestricted in both profiles.PR_SET_MDWEis not used, so outside systemd there is no W^X enforcement.apply_linux_capability_bounding_setreturns success even if everyPR_CAPBSET_DROPfails (runtime_hardening.cpp:405-409).
Area 8 — coverage gaps¶
- Not examined:
src/platform/elf_probe.cpp, BSD pledge and Capsicum behaviour beyond a static read, and the OpenRC and BSDrc.dscripts.
Area 9 — Media repository and thumbnailer¶
Result: 8 findings confirmed: 5 medium, 3 low. The verifier also re-found OUT-7, independently.
Design facts that remove whole attack classes:
- Blobs live in memory and in the database, not on the filesystem, so path traversal
and filename collisions do not apply.
- The stored Content-Type is always the server-sniffed type, from png, jpeg, gif, pdf,
text/plain or octet-stream. Stored SVG or HTML XSS is therefore not reachable.
- Media IDs are 128-bit random values from the CSPRNG.
- The federation multipart boundary is random.
- The decoder uses libpng's simplified API and turbojpeg, with no C++ frames between a
setjmp and its longjmp.
Prior fixes that hold: - 0.12.5 #8. - M05 random IDs. - M-09 / #448. - #443/#444 (media ID shapes). - #445 (markup sniffed as text). - #449, for the target size. - #418. - The 0.12.13 decoder hardening.
Regressed: the M05 legacy freeze (MED-1).
MED-1 — The freeze on legacy unauthenticated media downloads is lost on every restart¶
- Severity: medium (upper end) · Attacker: A1 holding an mxc URI · Verdict: confirmed and re-checked by the orchestrator; adjusted from high because the spec says SHOULD
- Location:
src/homeserver/runtime.cpp:124-153(hydrate_media_repository)include/merovingian/media/repository.hpp:59(defaulttrue)src/media/repository.cpp:637,src/homeserver/media_service.cpp:1084- Spec: "servers SHOULD "freeze" the deprecated, unauthenticated, endpoints to prevent newly-uploaded media from being downloaded … any media uploaded after (or during) the freeze SHOULD only be accessible through the new, authenticated, endpoints."
- Rule: ADR-0068 ("the freeze survives any backup/restore"),
docs/media-repository.md:135,docs/database-persistence.md:618. - Path:
- An upload stores
legacy_endpoint_visible=false, and the loaders read it back. - Hydration never copies the flag, so after a restart every record has the struct
default
true. GET /_matrix/media/v3/download|thumbnail/…is served before authentication and passes the check.- Impact: after any restart, all media uploaded since the upgrade can be downloaded without a token. No test covers restart.
- Fix:
- Copy the flag in hydration.
- Make the struct default
false, failing closed. - Add a restart test.
- Test: GIVEN a post-upgrade upload WHEN the runtime restarts from the same database
THEN
GET /_matrix/media/v3/download/{s}/{id}returns 404 AND the v1 authenticated route returns 200.
MED-2 — Quarantined remote media is served anyway, and its bytes leak into error bodies¶
- Severity: medium · Attacker: A1 or A2 requesting; A3 or A7 supplying the content · Verdict: confirmed
- Location:
src/media/repository.cpp:586-616,:865-880src/homeserver/media_service.cpp:501-505,:1055-1074src/homeserver/local_http_router.cpp:422-425src/homeserver/client_server.cpp:7550-7554- Rule: the default
remote_fetch_media_policyisquarantine, meaning "held for admin review" (config.hpp:415-419). - Path:
- A quarantined upload returns
ok=truewith status 202. - Remote fetch passes that on with the bytes. Nothing checks the
quarantinedflag. - Thumbnail requests accept any 2xx, so they decode the held image and serve a 200 thumbnail.
- Download requests turn the 202 into
dispatch_err(202, "M_UNKNOWN", "<ct>|<raw bytes>"). The held content is embedded in the JSON error body, and remote downloads are broken on the default policy. - Fix:
- Carry
quarantinedthrough the remote-fetch result and refuse to return bytes when it is set. - Never put payload bytes in an error string.
- Require exactly 200 in the thumbnail path.
- Test: GIVEN the quarantine policy and a remote serving a valid PNG WHEN v1 download and v1 thumbnail are requested THEN neither response contains image bytes or a 2xx status.
MED-3 — Re-uploading content an admin removed corrupts its database row, and a later removal does not erase it¶
- Severity: medium (integrity; moderation) · Attacker: A2 · Verdict: confirmed
- Location:
src/media/repository.cpp:60-66,:386-392,:522-535,:763-795src/homeserver/media_service.cpp:49-62,:1093src/database/persistent_store.cpp:2690- Path:
- Removal leaves a dead blob (ref 0, bytes cleared).
- A re-upload of the same bytes appends a second blob with the same
storage_id. find_local_media_blobreturns the first match, which is the dead blob.- Persistence then upserts the dead blob over the database row, so the live bytes are never stored. After a restart the re-upload returns 500.
- Thumbnails fail immediately.
- A later admin removal decrements the dead blob, so the live copy is never erased from memory or the database.
- Fix:
- Keep one blob per
storage_id, reviving or reusing the dead one. - Make every lookup consider only live blobs.
- Test: GIVEN media removed and then re-uploaded WHEN the runtime restarts THEN it downloads intact AND WHEN the re-upload is removed THEN its bytes are cleared in memory and in the database.
MED-5 — allow_remote is ignored and server names are compared exactly, so the server can fetch from itself¶
- Severity: medium · Attacker: A1 (reachable on the default configuration through OUT-7) · Verdict: confirmed; whether the loop actually forms is rated likely · Status: fixed in 0.12.19
- Location:
src/homeserver/media_service.cpp:770-783,:1017,:1055;src/homeserver/local_http_router.cpp:1109-1135 - Spec: "
allow_remote… Indicates to the server that it should not attempt to fetch the media if it is deemed remote. This is to prevent routing loops where the server contacts itself." - Path:
- A request for
/_matrix/media/v3/download/EXAMPLE.com/<id>, orexample.com:8448, is treated as remote. - Discovery resolves to this server, which fetches from itself.
- The fallback carries
?allow_remote=false, but the parameter is never read inbound, so the chain can repeat. Each hop holds a worker for up to 120 s. - Fix:
- Honour
allow_remote=falseon inbound download and thumbnail requests. - Canonicalise server names (lowercase, default port) before comparing.
- Reject discovery results that resolve to this server's own listener.
- Test: GIVEN
?allow_remote=falseor a case variant of the local name WHEN media is requested THEN no outbound request is made and the response is 404.
MED-6 — Media quotas default to unlimited, and blobs are held twice in memory¶
- Severity: medium (a documented default, but its consequence is not) · Attacker: A2 · Verdict: confirmed
- Location:
include/merovingian/config/config.hpp:396-406src/homeserver/runtime.cpp:145-149src/database/persistent_store.cpp:2718-2727src/media/repository.cpp:60-83- Detail:
max_total_size,max_size_per_userandmax_recordsall default to no limit. 0.12.5 finding 19 was fixed as opt-in only.- Every blob is held in
repository.blobsand again instore.media_blobs. - One account can store about 1 GB a minute per IP (50 MiB × 20 per minute), which is about 2 GB of RAM.
- Fix:
- Ship non-zero default caps.
- Stop keeping blob bytes in the persistent-store mirror.
- Index records and blobs.
- Test: GIVEN the default configuration WHEN one user exceeds the default per-user quota THEN the upload gets 507.
MED-4 — Thumbnail generation runs under the global runtime mutex¶
- Severity: low · Attacker: A2, or A1 through MED-1 · Verdict: adjusted from medium (decoding is bounded by the 4.1-megapixel cap and the media rate tier)
- Location:
src/homeserver/local_http_router.cpp:4063-4070;src/homeserver/media_service.cpp:825-870 - Detail:
- The decoder fork and exec, pipe I/O and decode, up to a 10 s timeout, all run with the lock held.
- There is no thumbnail cache, so every request re-forks.
- This contradicts the project convention of releasing the mutex for slow work
(
docs/threat-model.md~1090). - Fix:
- Copy the bytes, release the lock around
generate_thumbnail, and re-acquire. - Add an LRU thumbnail cache and a per-user concurrency cap.
- Test: GIVEN a decoder stub that sleeps 5 s WHEN a thumbnail request and a
/versionsrequest run concurrently THEN/versionscompletes first.
MED-7 — Federation media download skips the trust-and-safety media policy¶
- Severity: low · Attacker: A3 · Verdict: confirmed
- Location:
src/homeserver/local_http_router.cpp:2243-2246;src/homeserver/media_service.cpp:64-75 - Detail: only record state (quarantined or removed) is enforced. Media policy rules and the policy-server hook apply to client routes only.
- Fix: route the federation provider through
media_policy_decision. - Test: GIVEN a media policy rule blocking ID X WHEN a signed federation request downloads X THEN the response is 403 or 404.
MED-8 — The thumbnail worker's crop path can request an enormous intermediate image¶
- Severity: low (contained by the sandbox) · Attacker: A2 · Verdict: confirmed
- Location:
src/media/thumbnail_worker_main.cpp:178-185,:248-266,:345-361 - Detail:
- The crop fill is
max(target, source × ratio)with no pixel budget. - A 1×4096 source cropped to 1000×4096 asks for about 16 GB.
resizethrows and there is notry, so the worker terminates. - Fix:
- Budget the fill in 64-bit arithmetic, or crop and scale in a single pass.
- Catch exceptions in the worker's
main. - Test: GIVEN a 4096×1 PNG WHEN a 1×2048 crop is requested THEN the worker returns a clean error.
Area 9 — hardening notes¶
- Declared types that cannot be sniffed (video, audio, WebP, docx,
text/plain; charset=…) mismatch the sniffed type. They are silently quarantined, answered 200 with a content URI, and then return 451 forever. make_multipart_boundaryfalls back to a constant boundary if the RNG fails (repository.cpp:679-682). The response should fail instead.media_id_is_safepermits NUL, control characters and backslash.- Quarantine and removal apply per record, not per content hash, so re-uploading quarantined bytes produces an available ID.
- The federation
/media/thumbnailendpoint and the/download/{server}/{id}/{fileName}route do not exist.
Area 9 — coverage gaps¶
- Not examined: the parent-side handling of a thumbnail worker crash, the
RLIMIT_ASvalue, and how the JSON serializer handles invalid UTF-8 indispatch_err.
Area 10 — Database, persistence and migrations¶
Result: 10 findings confirmed: 1 high, 3 medium, 6 low. The type half of DB-6 was
refuted.
How DB-1 was verified: the verifier checked it against a live PostgreSQL 16.13
cluster, driving libpq with the same PQexecParams call shape as the server.
What holds: - No SQL injection: every runtime value is bound as a parameter, and DDL identifiers come from an allowlist. - Prior fixes M-10 (the migration lease and ledger re-check), M-05, 0.12.5 finding 24, and fail-closed handling of a schema newer than the binary. - M-08 holds as documented: the values are redacted from logs, but stored in plaintext.
Only partly fixed: M-09 was fixed for media_blobs.bytes alone (see DB-1 and DB-6).
DB-1 — On PostgreSQL the server cannot read back its own signing key after a restart¶
- Severity: high (availability of the server identity; the default backend)
- Attacker: none needed
- Verdict: confirmed empirically
- Location:
src/database/persistent_store.cpp:1042-1055(write)src/database/postgresql_store.cpp:308-318,:369-420(BLOB translated to BYTEA),:571-586(read, not decoded)src/homeserver/room_service.cpp:302-307,:1856-1905,:1952-1966,:2170-2176- Rule:
docs/database-persistence.md: binary columns must round-trip byte-exactly.- The earlier report's Resolution section flagged this column as "very likely" broken and needing confirmation against a live PostgreSQL.
- Path:
- The secret (
secretbox:v1:…) is bound as a text parameter into aBYTEAcolumn, where it is stored intact. - On load,
row[4]is copied as returned. With the defaultbytea_output=hex, that is\x736563726574626f78…. - Only
media_blobsis decoded on load. decode_encrypted_secret_from_storagerequires thesecretbox:v1:prefix, so the value falls to the legacy base64 branch, which yields the wrong size.ensure_runtime_server_signing_keyreturnsnullopt, and the crypto provider holds no keys./_matrix/key/v2/serverthen returns 500 ("server signing key unavailable").- Empirical result:
- The exact upsert returned
\x736563726574626f783a76313a51554a44524556475230684a536b744donSELECT. convert_from(secret_key,'UTF8')returned the original, so the stored bytes are correct; only the read path is broken.- Impact: after its first restart, a PostgreSQL deployment cannot sign events or federation requests until the row is repaired by hand. No test round-trips this column through PostgreSQL.
- Fix:
- Decode BYTEA on load. Existing rows hold raw ASCII bytes, so this is backward-compatible.
- Set
binary=trueon the write path. - Audit every other
BLOBcolumn for the same asymmetry. - Add a PostgreSQL restart round-trip test.
- Test: GIVEN a PostgreSQL store holding a
secretbox:v1:signing key WHEN the store is closed and reopened THENsecret_keyequals the stored string ANDensure_runtime_server_signing_keyreturns the same key.
DB-2 — On PostgreSQL, the federation worker's room snapshot stops refreshing once a room passes 128 events¶
- Severity: medium (upper end) · Attacker: none needed · Verdict: adjusted from high (main remains authoritative for writes) · Status: fixed in 0.12.19
- Location:
src/database/postgresql_store.cpp:2024-2036,:2130-2140,:283-289src/database/statement.cpp:121-123src/database/persistent_store.cpp:1230-1238src/federation_worker/worker_event_loop.cpp:906-912- Spec: "When a remote server makes a request, it MUST be verified to be allowed by the server ACLs."
- Path:
load_room_snapshot_implbuildsIN ($1..$N)with one parameter per event, and never chunks it.prepared_statement_is_validrejects more than 128 parameters, soreload_roomfails.- The worker logs a warning and keeps serving
make_join,make_leave,make_knock,/backfill,/state,/state_ids,/get_missing_events,/hierarchyand directory queries from the snapshot it loaded at startup. - That snapshot carries stale ACLs, bans and state.
- Fix:
- Use
= ANY($1::text[])or a join, or chunk the list. - When a reload fails, mark the snapshot untrusted and route those reads to main.
- Test: GIVEN a PostgreSQL room with 200 events WHEN an
m.room.server_aclevent is committed andreload_roomruns on a second handle THEN the reload succeeds AND the denied server is refused.
DB-3 — The PostgreSQL backend silently runs on an in-memory store when database.uri_file is missing or empty¶
- Severity: medium · Attacker: operator error, or anyone able to delete the file · Verdict: confirmed
- Location:
src/main.cpp:214(allow_missing=true)src/homeserver/runtime.cpp:112-122,:603-608src/database/sqlite_store.cpp:1167-1169- Detail:
- The server starts as
opened=true, and every persist call reports success. - All users, tokens, revocations and rooms vanish on the next restart, and a new signing key is generated.
- Nothing tells the operator.
- Fix: when
backend=postgresql, treat an unreadable or empty URI as a fatal startup error. Reserve the memory backend for an explicit, test-only entry point. - Test: GIVEN
database.backend=postgresqland a missinguri_fileWHENstart_runtimeruns THEN it fails.
DB-5 — Security-relevant database writes are discarded with std::ignore¶
- Severity: medium (needs a database write fault) · Attacker: A2 holding a stale credential; users served removed content · Verdict: confirmed · Status: fixed in 0.12.19
- Location:
src/homeserver/media_service.cpp:975,:1120-1123,:1146,:1168-1172src/homeserver/auth_service.cpp:1298-1299,:1371-1376,:1629,:1773-1774,:1830src/database/persistent_store.cpp:832-847- Rule:
docs/database-persistence.md:735-741: "Auth and room mutations fail the request when required persistent writes fail." That is not true for these paths. - Detail:
- Admin quarantine and remove update memory and report success even if the database write fails. After a restart, the content is served again.
- Refresh-reuse revocation, logout, device deletion and password-change revocations ignore persistence failures.
revoke_access_tokens_for_devicereturns 0 on failure, which cannot be told apart from "nothing to revoke".- After a restart, the "revoked" tokens are hydrated as live. That matters most on password change, the compromise-recovery action. AUTH-1's correctness notes list one of these call sites.
- Fix: check every result, persist before applying, and return 5xx on failure.
- Test: GIVEN a backend that fails writes WHEN an admin quarantines media or a user changes their password THEN the request fails AND the reopened store matches memory.
DB-4 — The membership row is written in a separate transaction from the event and current state¶
- Severity: low (needs a crash or write fault in a narrow window) · Verdict: confirmed mechanics; adjusted from medium
- Location:
src/homeserver/room_service.cpp:962-980(and the similar sequences at:2885-2911,:4147-4182,:4390-4404,:4552-4590);src/homeserver/runtime.cpp:507-521 - Detail:
- A ban can commit while the membership row still says
join. - Hydration rebuilds
room.membersfrom that row, so the banned user regains read access. - Nothing reconciles the two at startup.
- Fix:
- Write the event, state, membership and invite cleanup in one transaction.
- Reconcile the membership table from
current_stateat hydration. - Test: GIVEN a ban event persisted but its membership write failed WHEN the store is reopened THEN the user is not a member.
DB-6 — PostgreSQL text parameters are cut at the first NUL byte, and state_key may contain one¶
- Severity: low (needs state power) · Attacker: A2 or A3 with state power ·
Verdict: adjusted. The
typehalf is refuted:event_type_is_validrejects control characters. - Location:
src/database/postgresql_store.cpp:308-316;src/events/event.cpp:214-220;src/core/query_params.cpp:85-100;migrations/001_initial_schema.sql:31 - Detail:
- libpq text parameters are C strings, so a
state_keyof"\0x"reaches the database as"". - That collides with the room's real empty-key row: it either fails the event with 500 or makes the database diverge from memory.
- SQLite binds by length, so the two backends behave differently.
- Fix:
- Reject NUL in
state_keyduring event validation, and in percent-decoded path values. - Reject NUL in text
BoundValues. - Test: GIVEN a PostgreSQL store WHEN an event arrives with
state_key="\0x"THEN it is rejected before any write.
DB-7 — Every persisted write opens a fresh database connection while the global mutex is held¶
- Severity: low (a documented design: ADR-0009 and capability-gaps line 28) · Verdict: adjusted
- Location:
src/database/postgresql_store.cpp:2205-2231;src/database/sqlite_store.cpp:1172-1181 - Detail: this is a throughput limit, and it amplifies the DoS findings in Areas 1 and 3.
- Fix: use the already-configured
database.pool_sizefor write connections. - Test: GIVEN 200 concurrent sends WHEN they are persisted THEN the connections opened
are at most
pool_size.
DB-8 — TLS to PostgreSQL is neither required nor documented¶
- Severity: low · Verdict: confirmed
- Location:
src/database/postgresql_store.cpp:1704-1719;docs/user-manual.md:1188 - Detail: libpq defaults to
sslmode=prefer, which neither verifies the server nor resists downgrade. - Fix: for non-loopback hosts, require
sslmode=verify-fullor an explicit opt-out, and document it. - Test: GIVEN a remote host with no
sslmodeWHEN the connection string is validated THEN it is rejected.
DB-9 — Connection-string redaction leaks passwords that are spaced or quoted¶
- Severity: low · Attacker: A6 with log access · Verdict: confirmed (the algorithm was ported and tested)
- Location:
src/database/postgresql_store.cpp:196-217,:1751-1813 - Detail:
password = 'sup er'is not redacted at all.password='sup er'becomespassword=redacted er'.- Both forms are legal libpq syntax, and the string is logged on every connection open.
- Fix: never log the connection string; log only the host and database name.
- Test: GIVEN
host=x password='a b'WHEN it is redacted THEN neitheranorbappears.
DB-10 — SQLite is opened without symlink, mode or secure_delete protections¶
- Severity: low (packaged
0750directories mitigate) · Attacker: A6 · Verdict: adjusted - Location:
src/database/sqlite_store.cpp:125-176 - Fix: open with
SQLITE_OPEN_NOFOLLOWand file mode 0600, and setPRAGMA secure_delete=ONandtrusted_schema=OFF. See also ISO-6. - Test: GIVEN a fresh store WHEN it is opened THEN the file mode is 0600 AND a symlinked path is refused.
Area 10 — hardening and correctness notes¶
- Text-ordered stream IDs. Stream IDs are
TEXTand loaded withORDER BY stream_id(sqlite_store.cpp:647-746). After a restart, to-device messages and account data load with "10" before "9". - Broad role grants.
packaging/postgresql/provision-roles.sql:92-102gives the runtime roleUPDATE/DELETEonaudit_log,admin_actionsandschema_migrations. The login role belongs to both the migration and runtime roles. - No-op pragma.
PRAGMA foreign_keys=ONdoes nothing, because no migration declares a foreign key. - Two migration sources. Migrations are compiled into
migration.cpp, whilemigrations/*.sqlfeed only--plan. Nothing checksums one against the other, so they can drift. - Push-rule documentation is wrong.
docs/todos/capability-gaps.md:76says push-rule CRUD is implemented. OnlyGETof the default ruleset exists (client_server.cpp:11400-11440), and thepush_rulestable is unused.
Area 10 — coverage gaps¶
- Not read:
schema.cppandmigrations/002–017, except through the compiled catalogue. - The key-backup, presence, filter, profile and notification persistence functions were not read.
Area 11 — Configuration, observability, admin and metrics endpoints, packaging¶
Result: 5 findings confirmed, all low. Two claims were refuted: access_token query
parameters reaching the audit log, and remote response bodies reflected into client
errors.
The auditor also proposed OPS-1, an unauthenticated audit flood. That is AUTH-1's mechanism, so it is recorded as an addendum to AUTH-1 below instead of a separate finding.
What holds:
- The config parser and validator refuse unknown keys, duplicate keys, out-of-range
values and insecure combinations.
- Admin routes require require_admin and are served on the client listener only.
- Metrics use static labels only, so there is no cardinality explosion.
- No exception text, SQL or file path reaches clients.
- The shipped example config keeps registration off and enforces TLS or reverse-proxy
declarations.
- Every wrap is a [wrap-file] fetched over HTTPS with a source_hash, and
verify-wrap-pins.sh enforces this.
- Prior fixes H-05, M-11, L-05, M-01, L-13, and 0.12.5 findings 22 and 23, all hold.
AUTH-1 addendum — The audit log is also loaded whole at startup and concatenated whole by the admin endpoint¶
- Location:
src/database/postgresql_store.cpp:1076-1089andsrc/database/sqlite_store.cpp:631-634:SELECT … FROM audit_log ORDER BY …with noLIMIT, intostore.audit_log.src/homeserver/runtime.cpp:950-981:GET /_merovingian/admin/auditbuilds a single string from every row while the runtime mutex is held.src/homeserver/client_server.cpp:8765,:8797,:8826-8827: the 503, 413 and 429 paths each write a further audit row carrying the raw target, which can be up to 8 KiB.- Effect: AUTH-1's flood becomes a startup-time memory and latency problem that survives restarts.
- Fix, in addition to AUTH-1's:
- Add retention and a
LIMITto the load. - Page the admin endpoint.
- Stop mirroring the whole table in memory.
OPS-2 — Raw request targets, query strings included, are stored in audit_log.target¶
- Severity: low (medium where a reverse proxy runs without
trusted_proxies, so all clients share one bucket) · Attacker: A1 · Verdict: adjusted from medium - Location:
src/homeserver/client_server.cpp:2831,:8765,:8797,:8826-8827;src/homeserver/local_services.cpp:106-123 - Spec: the registration-token validity endpoint takes the secret as a query
parameter ("
token| string | Required: The token to check validity of"). - Detail:
- When such a request is rate-limited, or answered 413 or 503, the audit row, and the
debug-level
audit.appendlog line, store?token=<registration token>in plaintext. - That defeats the Argon2id hashing of the token file.
- The diagnostic fields on the same lines already use
sanitized_http_target. docs/threat-model.md(~663) lists the token-in-logs leak as fixed, which is true of logs only.- Fix: sanitise and truncate
targetinsideappend_local_auditormake_audit_event, so every caller is covered. - Test: GIVEN a rate-limited caller WHEN it sends
GET …/registration_token/validity?token=SECRETTHEN no audit row or log line containsSECRET.
OPS-3 — Malformed media quota values silently mean "unlimited"¶
- Severity: low (needs an operator typo) · Verdict: adjusted from medium
- Location:
src/config/config.cpp:1083-1088(onlymax_upload_sizeis validated);src/media/runtime_media.cpp:35-46;src/config/AGENTS.md:29 - Detail:
security.media.max_total_size=10Gfailsparse_size_limitand becomes 0, which means no limit, with no warning.src/config/AGENTS.mddocuments suffixes (100M,1G) that the parser rejects.- Fix:
- Validate non-empty values in
validate(). - Correct the AGENTS.md text.
- Log the effective quotas at startup.
- Test: GIVEN
security.media.max_size_per_user=1GWHEN the config is validated THEN it is rejected.
OPS-4 — Token lifetimes accept negative or huge values¶
- Severity: low · Verdict: confirmed
- Location:
src/config/config_parser.cpp:627-640,:1123;src/homeserver/auth_service.cpp:460-467;src/homeserver/client_server.cpp:9728,:9768 - Detail:
- A negative value silently disables expiry, although only
0is documented to do that. - Values above about 9.2e12 ms overflow when converted to nanoseconds.
expires_in_msis advertised as 0 or negative for tokens that never expire.- Fix:
- Reject negative values and values above a sane bound.
- Omit
expires_in_mswhen there is no expiry. - Test: GIVEN
access_token_lifetime_ms=-1WHEN the config is validated THEN a finding is returned.
OPS-5 — The Docker image and the deb and BSD packages build at -O0, and so fail the startup hardening gate¶
- Severity: low (fails closed; documented as scaffolding) · Verdict: adjusted
- Location:
meson.build:7-11,:108-113Dockerfile:21-34scripts/build-deb.sh:14-23,build-freebsd-pkg.sh:14-21,build-netbsd-pkg.sh:28,build-openbsd-pkg.sh:18src/platform/hardening_self_check.cpp:42-43,:184-190,:222-223- Detail:
- With no
buildtype, Meson buildsdebug, so_FORTIFY_SOURCEis never defined andmain.cpp:950-955refuses to start. - The Dockerfile also lacks
libpq-devandlibpq5, copies neither worker binary, and pins its base image by tag rather than digest. - Fix:
- Pass
--buildtype=releasein every packaging path, or set it indefault_options. - Ship the worker binaries and runtime libraries.
- Pin the base image by digest.
- Add a CI step that runs the packaged binary's startup gate.
- Test: GIVEN the deb or Docker build WHEN the binary starts THEN every hardening
check reports
enabled.
OPS-6 — The bootstrap admin password file skips the secret-file checks¶
- Severity: low · Attacker: A6 · Verdict: confirmed
- Location:
src/main.cpp:495-505,:858-870;docs/user-manual.md:261-268,:1234 - Detail:
- The file is read with
ifstream(following symlinks) into a plainstd::string. There is no owner, mode or symlink check. - The manual's example writes the password to
/tmp/admin-pw, a path another local user can pre-create. - The manual says the server exits after bootstrap; the code continues to open its listeners.
- Fix:
- Apply
is_secure_secret_file, read intoSecretBuffer, and wipe it afterwards. - Change the manual example to a 0700 directory.
- Make the code and the manual agree on whether the server exits.
- Test: GIVEN a 0644 or symlinked password file WHEN the server starts with
--bootstrap-admin-password-fileTHEN startup is refused.
Area 11 — hardening notes¶
- Hot reload is not wired. No SIGHUP handler exists and
apply_reloadhas no production caller.src/config/AGENTS.mdandlogger.hpp:236-243still describe hot reload. - Reload diffing is incomplete. If reload is ever wired,
build_reload_plandoes not compare about 20 keys, includingdatabase.*,reverse_proxy.*,security.media.max_*,server.oidc.*andserver.sso.*. - Loose integer parsing.
std::stoulon severalserver.http.*, timeout and CORS keys accepts trailing junk and a leading-. server.trusted_proxiesis not validated. Malformed entries silently never match, which puts every client into one rate-limit bucket.- The log file is truncated on open, so a restart erases the previous run's log.
/_matrix/federation/v1/versiondiscloses the exact version.- meson.build sets no minimum version for system libsodium, OpenSSL, libpq or libcurl.
Overall summary¶
101 verified findings: 1 critical, 23 high, 31 medium and 46 low.
| Area | Critical | High | Medium | Low |
|---|---|---|---|---|
| 1 Authentication and sessions | 0 | 2 | 3 | 7 |
| 2 Client-Server authorisation | 0 | 4 | 4 | 4 |
| 3 HTTP transport and DoS | 0 | 3 | 3 | 2 |
| 4 Federation inbound | 1 | 5 | 3 | 3 |
| 5 Event engine | 0 | 5 | 4 | 3 |
| 6 Outbound and SSRF | 0 | 1 | 3 | 4 |
| 7 Cryptography and IPC | 0 | 1 | 1 | 4 |
| 8 Isolation and hardening | 0 | 1 | 2 | 5 |
| 9 Media | 0 | 0 | 5 | 3 |
| 10 Database | 0 | 1 | 3 | 6 |
| 11 Config, observability, packaging | 0 | 0 | 0 | 5 |
| Total | 1 | 23 | 31 | 46 |
Recommended fix order¶
The order puts the effort where the risk is.
- Room takeover and room confidentiality.
- FED-1:
send_joinstate not bound to the room. - FED-2: federation reads with no in-room check.
- CSAZ-1: sliding sync with no membership check.
- CSAZ-2 and CSAZ-3: read gates and history visibility.
- CSAZ-4: private read receipts.
Any local user, or any federating server, can take over or read rooms.
- Spec divergence in the event engine.
- EVT-1 to EVT-4 and EVT-6: restricted-join signature, the power-levels required level, partitioning, the topological sort, and negative power levels.
- FED-5 and FED-6: invites overwriting bans, and knocks recorded as joins.
These let a remote server get events accepted that conformant servers reject.
- Sandbox boundary.
- CRY-1: the signing oracle.
- ISO-1: pre-hardening threads.
- ISO-2: signal and rlimit syscalls.
- CRY-2: the IPC queue.
A compromised federation worker currently escapes its sandbox and signs as the server.
- Cheap unauthenticated denial of service.
- HTTP-1, HTTP-2 and HTTP-8: worker-pool starvation.
- HTTP-5: SIGPIPE.
- AUTH-1 and AUTH-11: unbounded audit rows and retained statements.
- AUTH-4: Argon2 under the global lock.
- FED-4: forged-signature lockout.
- FED-7: EDU fan-out.
-
OUT-7: the unenforced remote-fetch opt-in.
-
Durability and correctness of security state.
- DB-1: the PostgreSQL signing key.
- DB-2: stale worker snapshots.
- DB-3: silent in-memory fallback.
- DB-5: ignored write failures.
- MED-1: the legacy media freeze.
-
MED-2 and MED-3: quarantine and removal.
-
Everything else, in the order listed within each area.
Documentation found to be wrong about the code¶
These statements should be corrected with the fixes, per docs/AGENTS.md.
docs/threat-model.md::496-521, bad-faith resident server (FED-1).:372-379, forgery prevented by the worker split (CRY-1).:1137-1171, parking and per-IP caps (HTTP-1).:429-436, SQLite "readable" (ISO-4).:723and:786,deny_ip_ranges(OUT-8).:469, worker restart back-off (ISO-3).- ~
:663, registration token redaction (OPS-2). - ADRs:
- ADR-0015 (CRY-1).
- ADR-0065 (CRY-2).
- ADR-0068 and
docs/media-repository.md:135(MED-1). - ADR-0072 (HTTP-1).
- Other docs:
docs/hardening.md:174-178, SIGPIPE (HTTP-5).docs/auth-identity.md:509-541, lockout (AUTH-2).docs/http-transport.md:490-492and:546, path coalescing (HTTP-3).docs/database-persistence.md:735-741, failed writes fail the request (DB-5).docs/crypto-boundary.md:218-220(CRY-6).docs/todos/capability-gaps.md:76, push-rule CRUD (Area 10 notes).docs/todos/capability-gaps.md:95, redactions (CSAZ-11).- AGENTS.md files and code comments:
src/config/AGENTS.md, size suffixes and hot reload (OPS-3; Area 11 notes).src/appservice/registration.cpp:544-546, sender "created at startup" (AUTH-3).
Limits of this audit¶
- Static review, apart from targeted experiments. Nothing was built and no test suite was run. The verifiers ran scratch experiments for OUT-1 (libcurl and glibc), ISO-1 (per-thread seccomp) and DB-1, DB-6 and DB-9 (PostgreSQL 16.13 and libpq). Every other finding rests on reading the code and the spec.
- Coverage gaps are listed at the end of each area. The largest are:
- the appservice YAML loader and outbound client;
src/identity/;- the IPC frame serialisers;
- TLS private-key handling;
- BSD pledge and Capsicum behaviour;
- the persistence functions for key backup, presence, filters and notifications.
- Severities are relative to the attacker models above. Findings rated for A5 assume code execution in a worker has already been gained.