Changelog
0.12.47¶
Every database migration is collapsed into one v1 schema for the release (ADR-0224). The schema
was built by 26 steps, 25 of them there only to upgrade databases nobody runs; a fresh database
now gets every table in its final shape, and every index, from the single initial_schema step.
Upgrade compatibility is promised from 1.0.0, so a database an earlier build created cannot be
upgraded and must be recreated.
- CHANGED:
current_schema_version()is 1.migrations/holds only001_initial_schema.sql(62 tables, 4 indexes);002to026are gone, and their columns and indexes are part of the tables v1 creates, each at the position itsADD COLUMNgave it. The migration catalogs hold one upgrade step and one downgrade (which drops the schema). The v1-era table shapes, thevN_table_nameslists and theroomsspecial case inschema.cppare gone;core_indexesandinitial_schema_indexes(),create_index_sql()andcreate_index_if_missing_sql()are new. - CHANGED: from 1.0.0 the v1 step and
migrations/001_initial_schema.sqlare frozen, and a schema change is a new numbered migration (migrations/AGENTS.md). A unit test requires the file and the build's v1 step to match statement for statement. - ADDED: a database whose
schema_migrationsledger names a migration this build does not have, such as the rows 2 to 26 every build up to 0.12.46 wrote, is refused as soon as the ledger is read, before anything is migrated or hydrated, with a reason naming the row (schema_ledger_is_known). The forward-only seam (migrate_*_schema_to) refuses it too. - FIXED: a fresh PostgreSQL database is bootstrapped as
database.migration_rolewhen one is configured, and startup fails if it cannot be assumed. It was bootstrapped as the login role, so the migration role owned none of the v1 tables, contrary toprovision-roles.sql; with every table now in v1, the first post-1.0 migration would have failed itsALTER. - REMOVED: the 0.11.13 upgrade fixture (
tests/fixtures/upgrade/0.11.13/) and its scenarios, as ADR-0216 planned; the next fixture comes from the last 1.0.x release when 1.1 starts. The generator, the seam and the synthetic scenarios stay.docs/release-process.mdrequires a previous-series fixture only when a supported series starts, from 1.1 on; a pre-1.0 series start such as 0.13.0 adds none (PR #532 review). - TESTS (new):
tests/unit/test_database_persistence.cpp: "The v1 schema step creates every table in its final shape and every index" ([collapse]), "A schema ledger naming a migration this build does not have is refused" ([collapse][security]) and "Checked-in migrations hold exactly the v1 step this build runs" ([files]).tests/unit/test_database_schema.cpp: "create_index_sql generates DDL only for registered indexes" ([ddl][security]: an unknown name, table, injected columns or injected name gets no DDL).tests/integration/test_database_upgrade_flow.cpp: "A fresh SQLite/PostgreSQL database has every column and index of the v1 schema", "A SQLite/PostgreSQL database a pre-1.0 build created is refused, not upgraded" and "A SQLite database holding tables but no schema ledger is refused, not adopted". TESTS (changed): the migration runner, PostgreSQL bootstrap, schema inventory and homeserver bootstrap scenarios expect one step at v1; the upgrade-flow seam scenarios target v1; "PostgreSQL migrations execute as the migration role, never as the login role" drops the schema instead of one step and also requires the migration role to own every table it bootstrapped;test_worker_db_uri.cppreads quoted table names. TESTS (removed): the per-step scenarios for migrations 012, 013, 014, 015, 018 and 022, and the 0.11.13 fixture and previous-series account scenarios.
0.12.46¶
The documentation site takes the theme of the OMG Software organisation site (ADR-0223).
- CHANGED: the site opens in a dark scheme of OMG near-black (
#151616) with the OMG orange accent (#ff8954), whatever the system preference; the toggle offers a matching light scheme whose links and hover accent use darker oranges so they stay readable (PR #531 review). Header, tabs and footer are OMG near-black with a hairline rule in both schemes; buttons, cards and headings follow the organisation site's shapes and weights, and the home page hero carries its mono eyebrow label. - CHANGED: the site uses Arial and Consolas from the reader's system (
font: false) and no longer loads Roboto from Google Fonts, so a page view makes no request to another origin. - ADDED: the OMG four-square mark as the site logo (
docs/assets/omg-logo.svg) and favicon (docs/assets/omg-favicon.svg). - TESTS (new):
tests/tooling/test_docs_site.pygainsSiteThemeTests: the site opens dark with custom colours; it fetches no fonts from a third party; the logo and favicon are inert local SVG; body text, links and the hover accent meet WCAG AA contrast on the page and on cards in both schemes.
0.12.45¶
Sliding sync tells the client when a room's timeline is truncated. Element X, whose room list asks
for timeline_limit: 1, showed only the newest message of a conversation written on another
client: the server cut each timeline to its limit but sent neither MSC4186 limited nor
prev_batch, so the client took the one event for the room's whole history and had no token to
page back from. Opening the room raised the limit, but the server resent nothing older than the
room's delta floor.
- FIXED: a sliding sync room whose timeline holds fewer events than are visible to the user
(an initial or expanded room) or than arrived since its last send (an incremental one) carries
limited: true. Every non-empty timeline carriesprev_batch, the stream ordering of its oldest event, whichGET /rooms/{roomId}/messages?dir=b&from=pages back from without a gap or repeat; atimeline_limitof 0 gives one past the newest event withheld. - FIXED: when a request raises a seen room's
timeline_limitpast the limit the connection last built it with (or no limit is recorded), the room's latest events are resent, flaggedexpanded_timeline: truewhen any predates its last send (MSC4186 "Changes totimeline_limit"). Such a request answers without long-polling, once per requestpos, so a repeat at aposno stream advanced cannot spin the client (ADR-0221). A list that is new or whose config changed is windowed before that decision, so a sent room it newly holds with a larger limit is answered at once too (PR #530 review); an unchanged list uses its previous window. A connection keeps only the lists of its last request (MSC4186 lists are not sticky), so a list dropped and sent again is windowed afresh and list names cannot grow the connection state. - FIXED:
num_livecounts the timeline's events since the previous request (it was always 0); a room's first send on a connection has none.expanded_timelineis judged against the room's own last send, so an event that arrived after it but before the request'sposdoes not set it (PR #530 review). - CHANGED:
SlidingSyncConnectionStateandSlidingSyncPendingResponserecord each room's effectivetimeline_limit(room_timeline_limits); the pending response records the expansions it answered at once (expansions_answered).sync::build_room_responsetakes aTimelineExpansion, andsync::timeline_limit_expandsdecides it from the connection. - TESTS (new):
tests/unit/test_sliding_sync_timeline_gap.cpp([timeline-gap]:limitedandprev_batchon initial, incremental, expanded and zero-limit timelines;num_live;expanded_timeline; when a limit grows).tests/integration/test_sliding_sync_flow.cppgains "Element X can show the latest 50 messages of a conversation another client wrote" (60 messages from another device: Element X's room list gets the newest withlimitedand aprev_batchthat/messagespages back from) and "Opening a room in Element X sends its latest 50 messages at once" (aroom_subscriptionslimit of 50 on a long-poll answers at once with the latest 50,expanded_timeline, and aprev_batchto the other ten; the same request repeated long-polls). It also gains "A list added on a long-poll that grows a sent room's timeline_limit sends its history at once" and "A list dropped and sent again unchanged is windowed afresh before the long-poll decision". TESTS (changed): "MSC4186 timeline_limit caps the number of timeline events returned per room" looked for a/v3/sync-shapedtimeline.eventsobject and so asserted nothing; it now requires thetimelinearray to hold exactly one event.
Every process also logs the libraries it runs at startup, each marked static or dynamic (ADR-0222).
- ADDED: startup
INFOlinesLibrary <name> <version> (static)andLibrary <name> <version> (dynamic, <shared object>; built against <version>)for libsodium, OpenSSL, libpq, SQLite, libcurl and yyjson, in the server log (and--dry-run) and, prefixedFederation worker library, in the federation worker's. Linkage is read from the loader (dladdr(3)), so it is what the process runs; a dynamic library's line names the shared object and the version the binary was built against. - ADDED: the server asks the thumbnail worker once at startup for its libpng and libjpeg-turbo
versions and logs them as
Thumbnail worker library …, or why it could not (Thumbnail worker libraries not reported: …, a warning when thumbnailing is enabled). The request (MLQ1, answeredMLR1) takes the same sandboxed spawn as a thumbnail, and the worker answers it after its hardening.merovingian-thumbnail-worker --versionprints the same lines. libjpeg-turbo has no runtime version call, so it reports the version it was built against. - CHANGED:
media::generate_thumbnail's spawn and exchange moved, unchanged, intoexchange_with_worker, which the library report shares.core::LibraryVersion,core::describe_libraryandcore::library_version_log_lineare new; each owning module reports its library (crypto,database,http,canonicaljson::library_versions,homeserver::linked_library_versions). Meson gainsdl_dep, and passes libpq's and libjpeg-turbo's pkg-config versions, whose headers carry none. - TESTS (new):
tests/unit/test_library_versions.cpp([library-versions]: static detection, every server library reported with its linkage, log line forms, libpq and yyjson version numbers, the worker report's framing and refusals).tests/integration/test_media_thumbnail_flow.cppgains "the sandboxed worker reports the image libraries it runs" and "a thumbnail worker that cannot run reports no libraries". Smoke:server-library-versions-log-smoke-testandthumbnail-worker-version-smoke-test(which, in a-Db_coverage=truebuild, checks only the output: the decoder sandbox refuses the coverage data written at exit, so the worker ends with SIGSYS after printing). - CHANGED: the README shows a CodeRabbit pull-request reviews badge, linked to coderabbit.ai, beside the code-scanning badge.
0.12.44¶
Email pushers are delivered. With an SMTP relay configured (server.email.*), kind: "email"
pushers send a delayed, throttled digest of the unread count to the account's own email address;
without one they are refused as before (ADR-0219, ADR-0220).
- ADDED:
server.email.*config:smtp_url(smtps://orsmtp://with mandatory STARTTLS),require_tls(falseonly for a cleartext relay at a loopback IP literal, never with credentials),from,usernameandpassword_file(an owner-only secret file read for each email), connect and total timeouts,notification_delay_seconds(600) andnotification_throttle_seconds(3600). Every key requires a restart; startup refuses a relay whose scheme the linked libcurl cannot speak. - ADDED:
src/email/, a module for outbound email:is_safe_mailbox_address(refuses, never repairs, any address that could inject an SMTP command or header),parse_smtp_url,build_unread_notification_email(RFC 5322, US-ASCII, the unread count and server name only) andsend_smtp_message(one libcurl SMTP session: TLS 1.2 or later, verification on, no proxy, no connection reuse). - ADDED:
push::EmailDigestQueueand theemail-pushjob thread (homeserver/email_push.cpp). A notification schedules one email per pusher after the delay, never sooner than the throttle after the last; more notifications fold into it; an email that finds nothing unread when due is not sent. A failed send is retried after 1, 2, 4, 8 and 16 minutes and given up at the sixth failed attempt, about half an hour after the first. The queue is in memory and holds at most 4096 pushers. - CHANGED:
POST /_matrix/client/v3/pushers/setacceptskind: "email"whensmtp_urlis set, withapp_idm.email(spec) and apushkeythat is an email 3PID already on the account (compared case-insensitively); anything else is 400M_INVALID_PARAM. Without a relay the 0.12.3 refusal is unchanged. Deleting an email pusher also drops any email due to it, and akind: nulldelete forapp_idm.emailmatches the address in any letter case, so every email pusher for that address goes (found in review). Every otherapp_idstill matches exactly. - CHANGED:
POST /_matrix/client/v3/account/3pid/deletedeletes the account's email pushers for the address it removes, and answers 500M_UNKNOWNinstead of 200 when the 3PID or one of those pushers could not be deleted (the 3PID's result was discarded before; found in review). A retry finishes the removal. The email job re-checks the pusher, the 3PID and the account before each send, deletes a pusher whose address has left the account, and never mails a deactivated account. - CHANGED: push delivery computes the Push Gateway badge count only when an
httppusher will carry it. - SECURITY: the federation worker clears
server.emailin its config copy, so no worker code path can send email or open the SMTP password file.server.email.password_fileis also inplatform::worker_landlock_secret_paths, so the worker refuses to start when a Landlock rule (such as the SQLite database directory grant) would cover it (found in review). - CHANGED:
src/meson.buildbuildshttp,platformand the newemailmodule beforeconfig, whose validation calls email's address and URL checks. - FIXED (found by this PR's CI):
src/email/smtp_endpoint.cppdid not compile on FreeBSD or OpenBSD: it relied on glibc's<arpa/inet.h>declaringAF_INET/AF_INET6, which POSIX puts in<sys/socket.h>. It now includes<sys/socket.h>. - FIXED (found by this PR's CI):
test_email_push_flow.cppfailed under ASan. The mock relay records a session after answeringQUIT, and libcurl returns as soon as it reads that answer, so the test could check for a delivered email before the relay thread had recorded it. Every assertion that a session happened now waits, at most ten seconds, for it to be recorded, and every assertion that the relay was never contacted reads a count of connections taken asaccept()returns (found in review: the recorded sessions could miss one still in progress). - FIXED (found by this PR's CI): CodeQL flagged three long functions with few comments
(
cpp/poorly-documented-function).send_smtp_messagenow applies its libcurl options through aconfigure_sessionhelper whose groups each name the ADR-0220 rule they enforce, and two long test scenarios are split into accepted and refused cases. - FIXED (found by this PR's CI):
rhel-build-and-testfailed every email scenario that needs an SMTP session: AlmaLinux 10 linkslibcurl-minimal, built without SMTP, so no session could start. The server already refuses to start with such a relay; the scenarios now skip, naming the reason, when the linked libcurl lacks the protocol.docs/user-manual.md,docs/platform-support.mdanddocs/dependencies/libcurl.mdtell RHEL operators to install the fulllibcurlfor email. - TESTS (new):
tests/unit/test_email.cpp,tests/unit/test_push_email_digest_queue.cpp,tests/unit/test_config_email.cpp,tests/integration/test_email_push_flow.cpp(real SMTP sessions against a loopback mock relay: implicit TLS with AUTH, STARTTLS, a relay without STARTTLS, cleartext loopback; read-before-due; address removed; pusher deleted with an email due; deactivated account; unreadable password file; stop requested mid-batch). TESTS (changed):tests/conformance/test_client_server_conformance.cppgains the relay-configured[email-pusher]scenario;tests/unit/test_worker_landlock.cppgains an SMTP password file beside the SQLite database scenario, and the shipped layout lists seven secrets. - DOCS: ADR-0219, ADR-0220;
docs/user-manual.md(server.email.*),config/merovingian.conf.example,docs/architecture.md,docs/threat-model.md,docs/observability-audit.md(push.email.*events),docs/security-coding-rules.md,docs/dependencies/libcurl.md,src/email/AGENTS.md.docs/todos/production-milestone.mdno longer lists email pushers as a 1.0.0 blocker: the refusal in 0.12.3 already met that row, which stayed listed by mistake.docs/todos/capability-gaps.mdmarks email pushersruntime-wired.
0.12.43¶
Meson wrap pins brought up to date; unused wraps for OS-supplied dependencies removed.
- CHANGED:
subprojects/sqlite3.wrappins SQLite 3.54.1 (WrapDB3.54.1-1, was 3.53.1),subprojects/yyjson.wrapyyjson 0.13.0 (was 0.12.0),subprojects/catch2.wrapCatch2 3.16.1 (was 3.14.0) andsubprojects/curl.wrapcurl 8.23.0 (was 8.20.0; used only by the static musl build). Everysource_hashand the SQLitepatch_hashwere computed from the downloaded archives; the SQLite and curl archives were identical from both mirrors, and the curl tarball's signature verifies against Daniel Stenberg's key. - CHANGED:
meson.buildaccepts a system yyjson only at>=0.13.0(was>=0.12.0). yyjson 0.13.0 fixes an uninitialised read and an integer truncation in number parsing, both reachable from untrusted canonical JSON. The adapter needed no change: 0.13.0 keeps the read API and error codes 0-14, and its newYYJSON_READ_ERROR_DEPTHonly fires whenYYJSON_READER_DEPTH_LIMITis set, which it is not; the canonical JSON parser keeps enforcing its own 64-level limit. - REMOVED:
subprojects/openssl.wrap(OpenSSL 3.0.20, a line upstream no longer supports),subprojects/libsodium.wrap,subprojects/libpq.wrap(PostgreSQL 18.0) and theirsubprojects/packagefiles/directories. These dependencies areallow_fallback: false, so no build ever fetched them (ADR-0218). - FIXED: a committed
include/openssl/configuration.h, generated from an OpenSSL 3.0.20 wrap build, shadowed the OS package's own header, because every target compiles with-I includeahead of the system include path. It is deleted, and a tooling test keepsinclude/openssl/from coming back. - FIXED (found by this PR's CI):
scripts/check-startup-hardening-gate.shcould fail a healthy server with "server exited with status on SIGTERM". Its wrapper wrote the exit status with a redirect, which creates the file before writing the number, and the gate stops waiting as soon as the file exists. The status is now written to a temporary file and renamed into place. - FIXED: the curl packagefile passed
--without-librtmp, an option curl removed with RTMP support in 8.20.0; autoconf only warned about it. It is dropped. - CHANGED: the curl source directory in
tests/meson.buildandscripts/validate-phase1-config.shmoves tocurl-8.23.0, and the tooling tests now read it fromcurl.wrapinstead of hard-coding a version..gitignoreignores extracted wrap sources of any version. - DOCS:
docs/dependencies/records the new pins;openssl.mdstates that the OS OpenSSL must be a branch that still receives security fixes (3.5 LTS or newer, or a vendor-backported older branch);libcurl.mdno longer claims the static build uses Alpine'scurl-static, which it cannot (-Dstatic_curl_wrap=true).
0.12.42¶
Production milestone blocker: a database written by an older release is now opened, migrated and served by the candidate, in CI (ADR-0216).
- ADDED:
tests/fixtures/upgrade/0.11.13/, a SQLite database and a PostgreSQL dump written over HTTP by release 0.11.13 (the last of the 0.11 series, schema version 11), with a manifest of what was stored.tests/integration/test_database_upgrade_flow.cppstarts the current runtime on each and reads everything back: old access tokens and passwords, messages, aliases, media bytes, profile, account data, pushers, device keys, an old sync token, a retried send replaying the event the old release stored for that transaction ID, and a new event (under a transaction ID the old release never saw) whose signature verifies against the signing key the old release stored, so a runtime that silently signed with a replacement key would fail.scripts/generate_upgrade_fixture.pyrebuilds a fixture from an old release's commit;docs/release-process.mdsays when. It fails rather than writing a fixture that lacks anything the test reads back (signed device keys need thecryptographypackage). - ADDED:
database::migrate_sqlite_schema_toanddatabase::migrate_postgresql_schema_tobuild a schema at an older version and stop there, so tests can seed that version's rows and open every older version at the current one. They only move forward, refuse a version this build does not know before touching the database, and refuse a schema already past the target. Both migration runners now take a target version and refuse a state already past it, so a racing process can never turn a migration into a downgrade plan. Tests also pin the failure paths: a schema the migrations do not expect (a column a later migration adds already present) stops the upgrade with the version unchanged and the store refusing to open, on both backends. - FIXED (found by the upgrade test): the PostgreSQL store wrote
profilesrows but never loaded them, so every displayname and avatar disappeared from the API at each restart. It now loads them like the SQLite store. - FIXED (found by the upgrade test): both stores read the migration ledger ordered by its
TEXTversioncolumn, so'10'sorted before'9'and a reloaded ledger's last entry was not the newest migration. The ledger is now read in numeric order. - CHANGED (coverage): gcovr runs with
--exclude-throw-branches(ADR-0217). The unwinding edge the compiler adds to every call that may throw made executed lines read as partial, since only an injected allocation failure reaches it. Decision branches still count. The flag needs GCC's gcov (clang'sllvm-cov gcovdoes not label those edges, so on clang it excluded nothing), so the coverage job alone now builds withgcc-14/g++-14and reads its data withgcov-14; every other job stays on clang. Branch figures change project-wide in the first report after this because the measurement changed, not the tests.tests/tooling/test_coverage_workflow.pypins the flag, the compiler and the gcov executable as real configuration lines, so a comment cannot satisfy the check and none of the three can change alone. - FIXED (first GCC build): the 30
static_cast<void>(…)/(void)…discards left in the tests are nowstd::ignore = …, as the coding rules require. GCC, unlike clang, does not let a void cast silencewarn_unused_result(sodium_init, POSIXwrite), so-Werrorstopped the GCC coverage build on them. GCC also rejected (-Wtype-limits) a conformance assertion that could never fail:REQUIRE(envelope->depth >= 0U)on an unsigned field, under "depth is populated". It now requires the fixture PDU's actual depth (== 5U), so it tests what it claims. A local GCC 14 build withninja -k 0found no other GCC-only error. - TEST DIAGNOSTIC: "DB-6 PostgreSQL inbound admission rejects escaped NUL state keys" failed
once in CI (
captured_statementsnot empty after a rejected PDU) after passing on nine earlier heads and four local runs. Its check now reports the names of the statements written, so the next failure identifies the leaking write instead of only saying one happened. - ADDED (gate):
scripts/reject-unsafe.shrejects void casts (static_cast<void>(…)and C-style(void)expr) in C++ files underinclude/,src/andtests/, so the rule is enforced at commit time and in CI, not just written down. Empty parameter lists (int f(void)), function types (std::function<void(int)>), comments and text inside string or character literals are not flagged, and a//inside a literal (a URL) does not hide a later cast; single-line raw literals (R"tag(...)tag") are read whole the same way; C files are out of scope (C has nostd::ignore).tests/tooling/test_reject_unsafe.py(reject-unsafe-tooling) runs the gate on planted files; it also rejects every void cast that was onmainbefore this change. - FIXED (gate passed without checking on the BSDs): every
reject-unsafe.shrule usedgrep -P, which the NetBSD, FreeBSD and OpenBSD greps do not have. Each such grep exited 2, the rules read that as "no match", and the gate passed on those hosts while checking nothing (CI enforcement was unaffected: the gate itself only runs on the Ubuntu jobs). The gate now usesgrep, elseggrep(GNU grep on the BSDs), and refuses to run (exit 2) when neither has-P. Found when its new tooling test failed on the NetBSD VM; the rule tests now skip on a host without such a grep, and a test that puts a-P-less grep onPATHchecks the refusal everywhere. - KNOWN, not carried over: 0.11 listed every room whose join rule was
publicin/publicRooms. Since migration 018 a room is listed only when published, so a room upgraded from 0.11 is unlisted until a room administrator publishes it again. There are no deployments yet and the migrations collapse into one at 1.0.0, so no backfill was added (owner decision, ADR-0216).
0.12.41¶
Media part of the conformance audit (2026-10-17): R17 and the wave-6 media endpoints.
- FIXED (R17):
GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}and the deprecated/_matrix/media/v3/download/.../{fileName}answered 404 for media that existed: the local router refused the third path segment. They now serve the media, andContent-Dispositionnames the requested file, as the spec requires. A name is written as an RFC 6266 quoted string (printable ASCII) orfilename*=utf-8''percent-encoding, and a name with a control character, a path separator or invalid UTF-8 is left out (media::media_content_disposition, ADR-0211). The deprecated named route stays frozen for new media (ADR-0068). Named downloads have their own rate-limit bucket (.../download/{server}/{mediaId}/{fileName}). - FIXED (found while fixing R17): an upload's
filename(?filename=) was discarded, so a download'sContent-Dispositionnever carried it, although the spec says it "MUST contain the samefilename". It is now stored (media.upload_name) and served back; a remote server'sContent-Dispositionfilename is kept with its copy. An upload whosefilenamehas a control character, a path separator, invalid UTF-8 or more than 255 bytes is refused with400 M_INVALID_PARAM(ADR-0211). - FIXED (found while fixing R17): an upload over a quota answered
507 M_LIMIT_EXCEEDEDand a file type the server does not permit415; the spec answers both with403 M_FORBIDDEN. Both upload routes now do, and an oversized upload is413 M_TOO_LARGE. - ADDED (wave 6, approved): asynchronous uploads.
POST /_matrix/media/v1/createreserves anmxc://URI (unused_expires_at, 24 hours by default);PUT /_matrix/media/v3/upload/{serverName}/ {mediaId}fills it once (409 M_CANNOT_OVERWRITE_MEDIA,404for an unknown or expired ID,403for another user's). Reservations are durable rows, capped per user (10) and server-wide (10000), andcreateis rate limited. A download of a reserved ID waits up tomin(timeout_ms, pending_upload_max_wait_ms)on the media fetch pool, never on a main-pool thread or underruntime.mutex, then answers504 M_NOT_YET_UPLOADED; the federation route answers504at once (ADR-0212). New config keyssecurity.media.pending_upload_ttl_seconds,max_pending_uploads_per_user,max_pending_uploads,pending_upload_max_wait_ms. - ADDED (wave 6, approved; off by default):
GET /_matrix/client/v1/media/preview_urland the deprecated v3 route, behindsecurity.media.url_preview_enabled=false. Every hop (page, redirects,og:image) is resolved, refused if any address is private, loopback, link-local, CGNAT, reserved or indeny_ip_ranges, and pinned to the checked addresses; redirects are followed by the gate (at most 3), within one deadline (10 s) and byte caps (1 MiB page, 5 MiB image). HTML is scanned by a boundedog:*/<title>extractor outside the runtime lock; the image is stored through the upload pipeline; every failure is the same502. Previews run on the media fetch pool, are rate limited per user, and are cached by URL (ADR-0213, ADR-0214). - Review fixes (PR #524): a download waiting for a pending upload is admitted first: at most
media_fetch_threads - 1waits at once (half for one requester), and on a request thread an ADR-0079 client-outbound slot too; otherwise504 M_NOT_YET_UPLOADEDat once, so waits cannot fill the media fetch pool or hold main-pool workers. An upload the server fails to store answers500 M_UNKNOWN, notM_BAD_REQUEST. The stored filename crosses from the local router to the client API in a response field, not an internal header.UrlPreviewCachecannot be copied (its index holds iterators into its own list). URL previews use the shared UTF-8 validator. - Migration
026_media_upload_names_and_pending_uploads.sql(schema v26):media.upload_nameand themedia_pending_uploadstable. It follows wave 3's025_appservice_directory.sql. - FIXED (PR #524 review): with
server.http.media_fetch_threads = 1a pending-upload wait could take the media fetch pool's only worker. A single-worker pool now admits no wait: a download of a reserved, empty media ID answers504 M_NOT_YET_UPLOADEDat once.
0.12.40¶
Conformance audit wave 5 (push): R19, R15 and R14 of docs/conformance-audit-report-2026-10-17.md.
- FIXED (R19, push-rule mutation APIs absent):
PUTandDELETE /pushrules/global/{kind}/{ruleId},PUT .../enabledandPUT .../actionsare implemented. A new rule is enabled and becomes the most important user-defined rule of its kind unlessbefore/afterplace it (beforewins when both are given). Rule IDs starting with.(reserved for server defaults), containing/or\, or (room and sender rules) not a room or user ID are refused with400 M_INVALID_PARAM; a placement that names no rule answers400 M_UNKNOWN, and one relative to a server-default rule400 M_INVALID_PARAM. Server-default rules can be enabled, disabled and given new actions but not created or deleted. Historicaldont_notifyandcoalesceactions are stripped. A user's rules are kept as theirm.push_rulesaccount data and applied over the current server defaults (ADR-0204), so a change reaches the user's clients in theaccount_datasection of/sync, and push delivery now evaluates the user's own rules instead of the read-only defaults.PUT /user/{userId}/account_data/m.push_rulesand.../m.fully_readare refused with 405, as the spec requires for server-managed types. The conformance scenarios that asserted404 M_UNRECOGNIZEDfor these endpoints now assert the spec's answers. - SECURITY (bounded work under the runtime lock): rules are evaluated under
runtime.mutexfor every recipient of every event, so a user holds at mostserver.client_api.max_push_rules_per_useruser-defined rules (new, default 200, at most 10000;400 M_TOO_LARGEpast it, updates always allowed), and each rule is bounded: an ID of 255 bytes, patterns and condition strings of 512 bytes, 16 conditions, 16 actions, 4096 bytes of canonical JSON. Stored rules are re-validated when read. - FIXED (R15, unread counts ignored push rules): every unread count (
/syncunread_notificationsandunread_thread_notifications, sliding-sync counts and theby_notification_countsort, the Push Gateway badge) is now decided by the user's own push rules (ADR-0205): an event counts as a notification when the rules notify, and as a highlight when they notify with the highlight tweak. A muted room no longer counts, a keyword rule highlights, and a mention the rules do not notify for no longer counts as a highlight.sync::count_unread_by_threadstays the one walk and asks a per-requestsync::UnreadEventClassifier; delivery and counting build their evaluation context from the samepush_room_factshelpers. - FIXED (audit concern 1, with R15): unread counts no longer include events from before the user's current join, which their rules were never run for and whose content a keyword rule could otherwise probe.
- FIXED (R14, failed push notifications never retried): a notification the gateway answers with
an HTTP error, or cannot be reached for, is sent again after 5 s, doubling to at most 10 minutes,
and given up after 8 retries (ADR-0206). Retries wait in a bounded in-memory queue (1024
notifications, 32 per recipient) drained by one
push-retryjob thread, never on a request thread and never withruntime.mutexheld across the network call. A pushkey the gateway rejects loses its pusher and its queued retries; a retry whose pusher was removed or re-pointed is dropped. A notification skipped because its gateway's circuit is open is now retried instead of dropped. New log events:push.delivery.retry_scheduled,push.delivery.retry_abandoned(debug),push.delivery.retry_droppedandpush.delivery.gave_up(warning). - FIXED (PR #523 review): delivery loads each recipient's push rules once (the badge count reused
a second load under
runtime.mutex); sliding sync builds its unread classifier only after theM_UNKNOWN_POSand long-poll returns; a push retry that fails late in a long batch is scheduled from when it failed, not from when the batch began; and the log-field and capability-gap docs match the code. - SECURITY (PR #523 review, bounded work under the runtime lock): the push badge
(
counts.unread) re-ran the exact per-room unread walk for every joined room, each scanning the whole store, underruntime.mutexfor every recipient of every event.count_push_badgenow makes one walk of at most 10000 rows and saturates at 1000 (ADR-0207; see the stream-order entry below for how the walk runs)./syncunread counts stay exact. Rejected and soft-failed events no longer count as unread, and a rejected leave no longer ends a user's join for counting. - FIXED (PR #523 review, spec rate limits): the push-rule endpoints the spec documents as
"Rate-limited: No" (all
GET,DELETE, andPUT .../enabledand.../actions) could be answered 429; they now bypass the rate limiter.PUT /pushrules/global/{kind}/{ruleId}("Rate-limited: Yes") keeps its bucket, including for a rule namedenabledoractions. - SECURITY (PR #523 review, unread counts and history visibility): an event the timeline
withholds, such as one backfilled from before the user's join and stored after it, was still
counted (and highlighted by keyword rules), revealing something about history the user may not
read. Every counted event now passes the same
HistoryVisibilityfilter as the timeline (ADR-0205). - SECURITY (PR #523 review, per-room table walks under the runtime lock): the unread classifier
walked the whole
memberships, currentstateandeventstables again for each room it was asked about. In/syncthis happened per room for every request; in the push badge it happened for every recipient of every event. A capped badge also walked all of the user's read receipts again for each room. The classifier now builds its room facts and every room's join start once per request: one walk each of the memberships, the current state and the events, plus the user's profile. Events are looked up by ID (ADR-0215)./sync, sliding sync and the badge give the same counts as before. The badge's setup is still linear in the server's memberships and receipts tables, once per badge; per-user indexes on them are a follow-up (ADR-0207). - FIXED (PR #523 review, string notification levels): push evaluation read a room's
notificationslevels only when they were JSON integers. Room versions 1-9 allow a level written as a string ("This includes the nested values within theevents,notificationsandusersproperties", rooms/v9.md), so a room that set"room": "100"fell back to the default of 50, and a sender at 50 could notify everyone with@room. Each entry is now read withevents::decode_power_level, the decoder the authorization rules use, in the room version's encoding. An entry the rules would not read, such as a string in version 10 or later, still falls back to the default. - FIXED (PR #523 review, a capped badge could count what /sync does not): the badge's walk
followed the order rows were stored in, which is not stream order. A capped walk could reach a
message without reaching the user's later own message in the same thread, and count a message
they had read. The walk now runs newest first in true stream order over the indexed event table
(ADR-0215). It resolves each read receipt's position by event ID before it starts, and looks
thread parents up by ID at no cost to its budget. It stops as soon as every joined room is
settled: past a non-join membership change, or at a join under the unthreaded read marker. An
uncapped badge equals
/sync's count. A capped one counts only events above the lowest row visited, so it never counts one/syncwould not (ADR-0207, amended). - CHANGED (PR #523 review, one history filter per request):
build_room_response's convenience overload built aHistoryVisibilityfor the request and then a classifier that built a second one of its own. The classifier now borrows the request's filter, so the two share one set of lazy indexes. Counts are unchanged. - DOCS (PR #523 review):
docs/threat-model.mdsaid unread counts do not go through the history visibility filter and named only the join boundary as the mitigation. It, the security coding rules and the audit report now say every counted event, the push badge's included, passesHistoryVisibility::can_see, which is what excludes a backfilled pre-join event. - DOCS (PR #523 review):
docs/todos/capability-gaps.mddescribedcounts.unreadas the sum of/sync's per-room counts and said unread counts skip the history-visibility filter.src/sync/AGENTS.mdlisted the push badge among the counts made bycount_unread_by_thread(). Each now matches ADR-0205 and ADR-0207.
0.12.39¶
Indexes the in-memory event store by event ID and by stream order (ADR-0215). Deferred from the review of PR #523 (conformance audit wave 5), whose capped push-badge count (ADR-0207) could not stop early or look thread parents up cheaply.
- CHANGED (
database::EventTable):PersistentStore::eventsis now anEventTablethat owns the rows and keeps an event-ID → position map and a stream-order index of positions. The rows are read-only through it. Every change goes throughinsert/push_back,insert_all,erase_if,modify,extractorclear, so a writer cannot leave an index stale.modifycannot change a row'sevent_idorstream_ordering. An event ID is held at most once. - CHANGED (lookups): about fifty linear scans of every stored event by event ID, all under
runtime.mutex, are now hash lookups (find,contains,find_in_room,with_id). They includesync::event_thread_id's up to three parent hops, history visibility, room read access, sliding sync's required state, heroes and room list,/event,/context,/relations,/redact,/report, read markers, notifications, the send_join auth chain (which also built a per-request map of every event in the room), auth-event maps, server ACLs, the stored room version, appservice delivery, space hierarchy, power levels, state bookkeeping, the federation event query, and the database's own status, redaction, state-match and duplicate checks. - CHANGED (stream order):
events.in_stream_order()visits events by ascendingstream_orderingwhatever order they were hydrated (SQLite: noORDER BY; PostgreSQL: event-ID order, and an updated row moves) or committed in (stream orderings commit out of order across rooms). Hydration andreload_roomadd their rows withinsert_all, which sorts once instead of shifting the index per row. - TESTS:
tests/unit/test_event_table.cpp(new) covers lookups and stream order after out-of-order insertion, duplicates, removal, modification, bulk insertion and copies, and on SQLite after out-of-order hydration, a status update, a redaction and a room reload. PostgreSQL scenarios intests/integration/test_postgresql_persistence_flow.cppcover hydration after a status update moves the row.
0.12.38¶
Conformance audit (2026-10-17) wave 4: the Client-Server API findings R10, R11, R12, R13 and R16.
- FIXED (R10):
GETandPUT /_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type}were not routed. They now store and read room account data, which/syncand sliding sync deliver in the room'saccount_dataand which never falls back to the global type of the same name. A malformed room ID is400 M_INVALID_PARAM. APUTneeds a membership row in the room (404 M_NOT_FOUNDotherwise), and a new type past the newserver.client_api.max_room_account_data_per_room(default 100) in that room is400 M_TOO_LARGE; an update to a held type always succeeds (ADR-0197, refining ADR-0173). - FIXED (R11): both account-data
PUTs stored any body. A body that is not JSON is now400 M_NOT_JSON, JSON that is not an object400 M_BAD_JSON, and the server-managedm.fully_readandm.push_rules405 M_BAD_JSON, globally and per room. Nothing is stored. - FIXED (R12):
POST /registerwithoutusernamewas400 M_BAD_JSON. The server now generates the localpart, as the spec requires: 16 hex characters from the CSPRNG, redrawn if reserved, taken or in an application service's namespace (ADR-0198). - FIXED (R13): typing never expired, and a
typingthat was not a boolean counted as true.PUT /rooms/{roomId}/typing/{userId}now refuses a missingtyping(M_MISSING_PARAM), a non-boolean one or a non-integertimeout(M_BAD_JSON), a negativetimeout(M_INVALID_PARAM) and a body that is not JSON (M_NOT_JSON), changing nothing. Typing lasts the client'stimeout(30 s when omitted, at most 120 s) and, for a remote user, 60 s from their server's latest EDU. A new "typing-expiry" background job removes expired typing, wakes/syncand sliding-sync waiters with the room's newm.typing, and sendstyping: falseto remote servers for local users (ADR-0199). - FIXED (R16):
/searchcovered only rooms the user was joined to. It now covers every room the user has been in, including rooms left, kicked or banned from after a join, each event still judged by history visibility, so nothing sent after the user left is returned.include_stategives a left room's state as of the leave. Forgotten rooms, invites and knocks are not searched. - FIXED (data race, found in review): the federated
m.typingEDU sink, which the dispatcher runs withoutruntime.mutex, read and wrote typing state unlocked, racing the client typing route,/syncand the new typing-expiry job. It now takes the lock itself. - R12 follow-up: a
username(or legacyuser) that is present but not a string is now400 M_BAD_JSON, not treated as omitted, so it no longer creates an account with a generated ID. - R13 follow-up: setting a user typing wakes the typing-expiry job, so a deadline shorter than the job's current sleep (up to 5 s) is no longer missed.
- R20 (open):
POST /registerruns User-Interactive Authentication before the username checks the spec says must come first; recorded in the audit report, not yet scheduled. - FIXED (found in review): account-data and typing bodies were parsed with the canonical-JSON
parser, which refuses fractional numbers, so a valid body such as
{"order":0.5}was400 M_NOT_JSON. They now use the general parser (account data is never signed), and a fractional typingtimeoutis400 M_BAD_JSON. The sliding-syncaccount_dataextension also read stored account data with the canonical parser and sent{}for anm.tagwith anorder; it now delivers the content. The same canonical re-read was fixed where stored account data reaches a client or is rewritten: the sliding-sync response assembly, roominitialSync, sliding sync'sis_dm(m.direct), andcreateRoom'sis_directupdate ofm.direct, which started from an empty mapping and destroyed the user's existing direct rooms when theirm.directheld a fractional number. - R13 follow-up: the typing-expiry pass no longer rescans the typing table for every expired room while holding the runtime lock; it groups the table by room in a fixed number of passes.
- Tests: new
tests/conformance/test_account_data_conformance.cppandtests/conformance/test_typing_conformance.cpp; new scenarios for/registerwithoutusername,/searchof a left and a forgotten room, the room account-data caps, the room capped store, remote typing expiry and the new configuration key. The four conformance scenarios that asserted404 M_UNRECOGNIZEDfor room account data are replaced by normative ones. The ignoring-users scenario for a malformedm.ignored_user_listnow writes the bad row straight to the store, since the PUT API refuses it.
0.12.37¶
Conformance audit wave 3: the Application Service and Identity Service findings R07, R08 and R09,
the appservice ping and network directories, and createRoom appservice delivery.
- FIXED (R07): an appservice was interested in an event only through its sender, the room's
joined members, or the room's first alias, so an invite to one of its virtual users (who is not yet
joined) never reached it. Interest now includes the
state_keyof anm.room.memberevent (the target) and every alias of the room (appservice::EventInterestFacts). An invite received over federation for a room this server is not in, stored only as an outlier, is now queued too: no later transaction brings it again. - FIXED (R08):
receive_ephemeralis honoured.m.typing,m.receipt(a private read receipt only for a user in the appservice's namespaces) andm.presence(including the one a profile change sends) are queued in memory per appservice, coalesced by what they describe, bounded (512 items, 100 per transaction, 20000 namespace matches per presence update), and sent in the transaction'sephemeralarray, alone in an events-free transaction when no event is queued. A retry resends the same items under the same ID (ADR-0190). - FIXED (R09): the four
requestTokenendpoints forwardsend_attemptto the identity server. - ADDED:
POST /_matrix/client/v1/appservice/{appserviceId}/pingand the outboundPOST /_matrix/app/v1/ping, withduration_ms,M_URL_NOT_SET,M_FORBIDDEN,M_CONNECTION_FAILED,M_BAD_STATUS(status and a bounded body) andM_CONNECTION_TIMEOUT. The call releasesruntime.mutexand holds a client-outbound slot;transaction_idis copied as given. - ADDED:
PUT /_matrix/client/v3/directory/list/appservice/{networkId}/{roomId}, persisted in the newappservice_directorytable (migration025, schema version 25), andthird_party_instance_id/include_all_networksonPOST /publicRooms.instance_idis now<registration id>:<protocol>:<index>so it names the network (ADR-0192). An appservice may list only a room it is interested in, under a protocol it declares. - FIXED:
createRoomqueues the events it creates for interested appservices, as does the third-party invite stored on join. - SECURITY: the queue of one appservice is capped by
appservice.max_queued_events(default 100000). Further events are refused, counted (merovingian_appservice_outbox_dropped_total), logged and audited asappservice.outbox.full(ADR-0191, noted on ADR-0163). New metrics:merovingian_appservice_outbox_queuedandmerovingian_appservice_ephemeral_dropped_total. - SECURITY: every client-server route that forwards an
id_access_tokenrefuses one that is not printable ASCII without spaces of at most 4096 bytes (400 M_INVALID_PARAM), before the outbound client's own header check (ADR-0180).
0.12.36¶
Fixes the federation findings of the 17 October 2026 conformance audit (wave 2: R04, R05, R06, R18). No migration.
- FIXED (R04): this server could not be the resident that authorises a restricted-room join.
GET /make_joinnever named ajoin_authorised_via_users_server, andsend_joinnever added this server's signature, so every non-invited join into a restricted orknock_restrictedroom failed.homeserver::decide_restricted_joinnow checks the room's allow conditions against this server's current state and picks a joined local user with invite power.make_joinnames that user (and their member event among the auth events) or refuses with 400M_UNABLE_TO_AUTHORISE_JOIN, 400M_UNABLE_TO_GRANT_JOINor 403M_FORBIDDEN. An allow condition counts as verifiable only while this server is resident in the room it names: state kept after leaving is not current.send_joinco-signs only a join it authorises, before it is stored, after dropping any signature the joining server filed under this server's name; it returns the co-signed event. A local user's join into a local restricted room carries the field too, and a client cannot name an authoriser throughPUT /stateto skip the check. A join by a user who is already joined (a profile update that copies the join's content) has the field dropped instead, as m.room.member encourages, so it is not refused. Federation workers relaymake_jointo main, which holds every room the conditions name (ADR-0184, ADR-0185). - FIXED (R05): the sender's signature on a relayed PDU, an invite, a worker-relayed event, a
backfilled event and
send_joinstate was checked only against the first key ID listed for the sender's server. Every listeded25519key ID is now checked, as "Validating hashes and signatures on received events" requires: an unknown key, or one expired before the event'sorigin_server_ts, is skipped; a known, unexpired key whose signature does not verify refuses the event; at least one must verify. An event listing more than four key IDs for its sender's server is refused before any lookup, and a local key-lookup overload is a retryable 503 (ADR-0183). - FIXED (R06):
send_join,send_leave,send_knockand/invite(v1, v2) refused an event whose content hash did not match. The spec says such an event "is redacted before being processed further": the acceptor and the invite handler now redact it and authorise, sign, store and return the redacted copy. An invite for a room this server does not hold is redacted and signed under the request'sroom_version, not version 10. A signature failure is refused with 400M_INVALID_PARAM, as those endpoints say. - FIXED (R18):
GET /query/profilerefusedfield=m.tz(and any other key) with 400. Any field is a valid request; one the user does not have is 404M_NOT_FOUND, and an unset field is left out of a full profile instead of being sent as"". - FIXED: the
make_jointemplate provider read room state without the runtime lock. - DOCS: ADR-0183 to ADR-0185; the conformance audit report marks R04, R05, R06 and R18 fixed;
docs/event-engine.md,docs/threat-model.md,docs/security-coding-rules.md,docs/architecture.md,docs/todos/capability-gaps.mdand the federation, homeserver, federation-worker and IPC module rules updated.
0.12.35¶
- FIXED:
PUT /_matrix/client/v3/profile/{userId}/displayname(andavatar_url) answered500after storing the new value whenever one joined room or the presence write could not take the propagated update, so Element reported a failed change that had happened. A room that refuses every time (an unsupported room version, a missing create event, a non-federating room created elsewhere) made every later profile change fail as well. The PUT now answers200once the field is stored, as the endpoint's responses require; a room that fails is logged at warning (profile.propagation.room_failed) and is sent the update at the next profile change (ADR-0182). - FIXED: profile propagation chose rooms from the runtime member list, which can name a user the
room's state says has left. In a public room the join it sent then rejoined them. Rooms are now
chosen by the user's current
m.room.memberstate; a room only the list names is skipped (profile.propagation.not_member).
0.12.34¶
Fixes the six LOW findings from security audit run-1 (2026-10-20), and two defects found while fixing them.
- SECURITY (
sp02.is-token-crlf-header-injection): a client'sid_access_tokenreached the outboundAuthorizationheader unfiltered, and the pinned libcurl writes header lines verbatim, so CR/LF in it added header lines. The outbound client now refuses, before any network I/O, a header name that is not an RFC 9110 token or a value with a control character other than HTAB (OutboundError::invalid_header, ADR-0180). - FIXED (
fed.outbound-fanout-rfind-colon-port-server-name): a member's server name was taken after the last:of their user ID, so a member onhost:8448was addressed as8448and never received the room's events. Every user ID, room ID and alias split insrc/homeserver/now uses the first:(auth::identifier_server_name,auth::user_id_server_name): room-event and EDU fan-out, device-list destinations, key query/claim routing, invite targets and alias lookups. - SECURITY (
fed.outbound-fanout-unbounded-destinations-queue-poison): a destination whose circuit stayed open kept its transactions queued without counting retries, so one dead or hostile peer could fill the shared outbound queue and every other peer's transactions were refused. Each destination now holds at most its share of the queue (max(16, depth / 16), 256 by default), counting its queued and in-flight transactions, and replay after a restart parks rows past the share instead of promoting them. Refusals are counted (merovingian_federation_outbound_rejected_total) and the room-event fan-out logs them at warning level (room.event.dispatch_refused) instead of discarding them (ADR-0177). - FIXED (durable outbound queue): both fan-outs gave every destination one transaction ID, which
is the durable
federation_transactionskey. A fan-out kept one durable row, and the first delivery deleted it, so a restart could not replay the other destinations. Each destination now gets its own transaction ID. - SECURITY (
med01.remote-media-owner-sentinel-localpart-collision): remote media was told apart from local media by its owner starting with@remote-media:, which a local account namedremote-mediaimitated to escapemax_total_sizeandmax_records. Each media record now carriesfrom_remote_fetch, rebuilt on startup from the owner and the local server name, and the localpartremote-mediacannot be registered (400 M_USER_IN_USE, ADR-0178). - SECURITY (
rooms.room-version-resolution-divergent-fallbacks-create-without-room_version): components derived a room's stored version with different defaults ("1","10","12"), and a federated join never checked the create event against the make_join version. There is now one accessor (homeserver::stored_room_version): a create event withoutroom_version, or with an unusable one, is version 1 and has no policy; the accessor never returns an empty version, which several parsers would replace with"12". A join is refused (502) unless the create event declares the make_join version. Auth rule 1.3 also rejects aroom_versionthat is not a string (ADR-0179). - SECURITY (
sp02.thirdparty-no-proxy-slot): the/thirdparty/*routes called appservices without a client outbound proxy slot. Each now takes one before calling, and answers429 M_LIMIT_EXCEEDEDwhen none is free (ADR-0181). - OBSERVABILITY: new metrics
merovingian_federation_outbound_rejected_totalandmerovingian_federation_outbound_pending, and log eventroom.event.dispatch_refused. - TESTS: new scenarios for each finding, a conformance scenario for first-colon splitting of
every sigilled identifier and for rule 1.3 with a non-string
room_version, andtests/unit/test_stored_room_version.cpp. - DOCS: ADR-0177 to ADR-0181;
docs/threat-model.md,docs/security-coding-rules.md,docs/observability-audit.md,docs/http-transport.md,docs/media-repository.mdand thesrc/http,src/federation,src/homeserver,src/mediaandsrc/authAGENTS.mdfiles.
0.12.33¶
Fixes the five MEDIUM findings from security audit run-1 (2026-10-20) where a request could make the server do work, sized by its own input, while the server-wide runtime lock was held.
- SECURITY (sliding-sync extension rooms,
sp01.sliding-sync-extension-rooms-unbounded): thereceiptsandtypingextensions'roomsarrays were uncapped and not de-duplicated. Each named room cost a scan of every membership row (and, for receipts, every stored receipt). Each array is now capped at theroom_subscriptionslimit (default 256,400 M_INVALID_PARAMbeyond), repeated room IDs are kept once, and the rooms are classified throughsync::room_access_index, one pass over the caller's memberships built once per request. Receipts and typing then make one pass over their tables. The handler builds one index forroom_subscriptionsand passes it tobuild_extensions, so a request scans the memberships once (ADR-0174). - SECURITY (push glob matching,
sp01.push-glob-dp-allocation-per-character):event_matchallocated a full (value × pattern) table per match, and thecontent.bodymatcher tried every pair of word boundaries. Whole-value matching now compares exactly when the pattern has no wildcard, and otherwise uses a greedy matcher that backtracks to the last*with no allocation. Word-boundary substring matching is one pass over the body. The server evaluates only the default ruleset today (user push rules are not writable), so the body matcher was latent; it is fixed anyway (ADR-0175). - SECURITY (account data,
sp02.account-data-unbounded-linear-scan): a user could add global account-data types without limit, growing the rows every account-data read scans under the lock. Newserver.client_api.max_global_account_data_per_user(default 1000, 1 to 100000, restart required). A new type past it gets400 M_TOO_LARGE; updating a type the user already holds always succeeds. The check and the write share one pass over the rows (database::store_global_account_data_within_cap). Room account data is bounded by membership and is not counted (ADR-0173). - SECURITY (appservice namespaces,
sp02.appservice-regex-compile-under-mutex): each namespace regex was compiled on every match, once per member per event, under the lock.appservice::Namespacenow compiles its pattern once, when it is built, and shares the compiled form between copies. The pattern is private (regex()), so it cannot drift from the compiled form. A malformed pattern still matches nothing. - SECURITY (federation media multipart,
med01.federation-multipart-quadratic-boundary-scan): the delimiter search compared the boundary at every offset of a body a remote server controls, which a long, repetitive boundary made O(body × boundary) under the lock. A boundary longer than RFC 2046's 70 characters is refused, and delimiters are found with a linear Knuth-Morris-Pratt scan (ADR-0176). - TESTS: new
[security][sp01],[security][sp02]and[security][med01]scenarios cover each fix: the push matchers are checked against the old implementation on 3000 seeded cases,room_access_indexagainstroom_access_for, and the multipart parser against a pathological all-dash boundary and 1 MB of dashes. The receipts and typing grouping is checked across interleaved rooms. Newtests/unit/test_account_data_cap.cpp. - DOCS: ADR-0173 to ADR-0176;
docs/threat-model.md,docs/security-coding-rules.md,docs/user-manual.md,docs/media-repository.md, and thesrc/sync,src/push,src/appserviceandsrc/homeserverAGENTS.mdfiles.
0.12.32¶
- FIXED (federated restricted joins were always refused): a join into a restricted or
knock_restricted room by a user who is not invited carries
join_authorised_via_users_server. The auth rules then need that user'sm.room.memberevent to check they are joined with invite power (rule 4.3.5.2). "Auth events selection" names that event for such a join, and state resolution selected it, but the router's ingestion auth maps (build_auth_event_map_from_entries) never did. So/send,send_joinand backfill refused every such join, genuine or not. All eight map builds on those paths now select it. This fix depends on 0.12.31, which made every ingestion path verify the authorising server's signature first (ADR-0171). - TESTS: the worker-relay and backfill
[evt1]scenarios now use a restricted room instead of a public one. A genuine authorised join is accepted, forged authorising signatures are still refused, and a validly signed join naming a user who is not in the room is refused. - DOCS:
docs/todos/capability-gaps.mdmarks the 0.12.31 receiving-side gap resolved.docs/event-engine.mddescribes the auth-map selection.
0.12.31¶
Fixes the two HIGH findings from security audit run-1 (2026-10-20).
- SECURITY (forged restricted joins via worker relay or backfill): an
m.room.memberevent carryingjoin_authorised_via_users_servermust be validly signed by that user's server (rooms/v10.md rule 4.2.1). Only/sendandsend_joinverified that signature. Main's re-verification of PDUs relayed by the federation worker, and backfill / missing-history ingestion, checked only the sender's signature, so the structural auth rule accepted any non-empty authorising-server signature. In a restricted room this was latent: those paths' auth maps never carry the authorising user's membership, so they refuse every non-invited restricted join anyway (a separate bug, not fixed here). In a room with any other join rule, a join carrying a forged authorising signature was accepted, which rule 4.2.1 forbids. All four paths, and thesend_joinstate this server stores when it joins a remote room, now call one helper,federation::verify_join_authorising_server_signature. It resolves the authorising server's key through the caller's admitted lookup and refuses the event unless the signature verifies. A local key-resolution overload is retryable (503 /main_overloaded), never a verdict. Any listed authorising-server key may verify the event (up to four are tried), because a relaying server can add key IDs to the unsignedsignaturesobject; backfill stops starting candidate lookups at its deadline. Ajoin_authorised_via_users_serverthat is not a user ID names no homeserver, so the join is refused rather than treated as if the key were absent. The helper applies the rule tom.room.memberevents only, as the spec says; the previous/sendcode also applied it to other event types (ADR-0171). - SECURITY (one sliding-sync request could stall the server):
by_recencyandby_namewere computed inside the sort comparator, rescanning the event store on every one of O(N log N) comparisons while the runtime lock was held. Every sort key is now computed once per room before the sort:by_recencyin one newest-first pass that skips events that cannot beat the room's current maximum, andby_namein one pass over state and one over events. Requests over 32 lists, 3 sort keys per list or 64bump_event_typesper list are refused with 400M_INVALID_PARAM(ADR-0172). - TESTS: branch coverage for
verify_join_authorising_server_signature; forged and valid authorising signatures on the worker-relay (pdu_ingest,membership_ingest) and backfill paths, and in thesend_joinstate this server keeps when it joins (forged, absent, genuine); the sliding-sync ordering contract over ascending, descending and shuffled stores; and the new request caps at and over each limit. - DEPENDENCIES (docs toolchain):
certifi2026.7.22 → 2026.10.19 inrequirements-docs.txtfor GHSA-6q48-6c7w-9gw2 (critical, published 2026-10-20). Releases before 2026.10.18 bundle emSign root CAs whose intermediate CA keys were compromised; the advisory's version range names 2026.10.19 as the first patched release, which is what scanners enforce, so that is the floor.certifireaches only the MkDocs site build (throughrequests); the server does not use it.requirements-docs.infloors it at 2026.10.19, andtests/tooling/test_docs_site.pyrefuses a lock below that. The advisory also broke the dependency-triage job: Grype reported the docs-lock package with an empty location, and Code Scanning rejected the SARIF upload. - DOCS: ADR-0171 and ADR-0172; rules added to
src/federation/AGENTS.md,src/homeserver/AGENTS.md,src/sync/AGENTS.mdanddocs/security-coding-rules.md; threat-model entry;docs/event-engine.md; a correction to the EVT-1 residual note in the 2026-09-29 audit report, which said every PDU path verified the signature.
0.12.30¶
- FIXED (resident-supplied profile signed as ours): a federated join kept a
displaynameoravatar_urlfrom the resident server'smake_jointemplate whenever the joining user had not set that field locally, and this server then hashed and signed it as the user's profile. The template's profile fields are now dropped before the local profile is added. The live federated join scenario covers no profile, an empty profile, and display-name-only and avatar-only profiles against a template carrying forged values. - TESTS: give the thread-root
thread_idreceipt conformance scenario the required// Endpoint / Section:citation. - TESTS: the self-signed certificates in the join, outbound-federation and listener flow
tests are backdated an hour. They were valid from the instant of creation, so a backward
clock step under WSL made one "not yet valid" mid-scenario (
tls_verification_failed). - DOCS: include
receiptsin the worker table inventory and correct the worker-never-read total to 46 tables, matching the schema classification test. - CI / REVIEW: use an explicit string view for the notification-sort lookup
so GCC 16 can compile it; align the profile propagation definition's
[[nodiscard]]annotation and the threaded-receipt scenario's spec citation. - FIXED: commit inbound receipt eviction and replacement in one transaction. A failed delete or insert preserves the old receipt in memory and on disk. Add SQLite failure-injection and restart checks; correct the push unread-count documentation to describe thread-partitioned read markers.
- MERGE: integrate the 0.12.29 documentation and startup-example corrections, retaining the 0.12.30 versions and thread-aware receipt documentation.
- REVIEW FIXES: serialize inbound receipt callbacks with runtime readers; cache notification counts once per room before sorting sliding-sync lists; reject a thread reply supplied as a receipt's thread root; and report incomplete room or presence propagation with HTTP 500. Repeating the same profile update repairs missing updates without duplicating membership events already current. Add failure/retry, receipt-locking, thread-root, and notification-ordering regressions, and clarify the conformance assertions' specification rationale.
Two user-reported defects: a changed display name was never seen in any room, and rooms marked read came back unread.
- FIXED (display name and avatar never reached any room):
PUT /profile/{userId}/displaynameand/avatar_urlupdated only the profile row. Clients show members by thedisplaynameandavatar_urlof theirm.room.memberevents, so neither the user nor anyone else saw the change. Spec, "Events on Change of Profile Information": "an m.room.member event (with a join membership) is sent to every room the user is a member of".homeserver::propagate_profile_changenow sends that join, carrying the current display name and avatar URL, throughsend_eventto every joined room whose membership does not already carry it. It is federated, pushed and delivered to appservices like any other event. A room that refuses the event is skipped and logged. The section's second mechanism, "an m.presence presence status update ... containing the new values of the displayname and avatar_url keys, in addition to the required presence key", is sent too: the user's current presence (offline if never set) is stored again at a new sync stream position, and everym.presencein/syncnow carries the sender's display name and avatar URL when this server holds their profile. Presence is not federated by this server, so remote peers learn the change from the member events. A failed profile write now answers 500 instead of 200, as does incomplete room or presence propagation. The profile itself may already be saved; retrying the identical request repairs pending room updates while skipping rooms already current. The spec also says that membership events a server emits for its own users "should include the display name and avatar URL fields": local joins, the creator's join increateRoomand the join sent over federation aftermake_joinnow carry them. Scenarios tagged[profile-propagation]. - FIXED (rooms marked read came back unread): read receipts lived only in
HomeserverRuntime::receipts, in memory. Every unread count is computed from them, so a server restart reset every room to unread. They are now kept in areceiptstable (migration024, schema version 24), mirrored inPersistentStore::receiptsand written throughdatabase::store_receipt/remove_receipt.HomeserverRuntime::receiptsandInboundReceiptare gone. The inbound federation cap no longer evicts a local user's receipt, only the oldest receipt of a remote user (ADR-0169). Scenarios tagged[receipt-persistence]. - FIXED (threaded receipts):
thread_idwas ignored. A receipt in a thread replaced the user's main-timeline receipt of the same type, which could move the read position back and mark the room unread again. Receipts are now keyed by(room, type, user, thread_id). Spec: "Each user_id, receipt_type, and categorisation (unthreaded, or thread_id) tuple must be associated with only a single event_id".POST /receiptanswers 400M_INVALID_PARAMfor athread_idthat is not a string, is empty, is sent withm.fully_read, or is neithermainnor the ID of an event in the room (so a client cannot create a receipt row per invented thread). An inbound federatedthread_idover 255 bytes rejects the EDU.m.receiptin/syncand the sliding sync receipts extension carrythread_id(one sharedsync::ReceiptContentBuilder). Federated receipts carry it both ways indata.thread_id. - FIXED (unread counts ignored threads): counts are partitioned by thread as the spec
requires. An unthreaded receipt covers every earlier event. A threaded receipt covers only its
own thread (
mainor a root's event ID), resolved through up to 3 relation hops (sync::ReadMarkers,sync::read_markers_for,sync::event_thread_id).sync::read_receipt_orderingis replaced bysync::read_receipt_markers, andcount_notifications/count_highlightstakeReadMarkers. This applies to/sync, sliding sync, the room-listby_notification_countsort, the push-gateway badge andGET /notificationsread. Scenarios tagged[threaded-receipts]and[read-markers]. - NEW (per-thread unread counts):
/synchonours the RoomEventFilter flagunread_thread_notifications(read fromroom.timeline). With it set,unread_notificationscounts only the main timeline andunread_thread_notificationsholds the counts of each thread that has any, keyed by root event ID; without it the counts are summed as before.sync::count_unread_by_threadis the one count walk;count_notificationsandcount_highlightssum it. A highlight is now counted once per event rather than once per matchingm.mentionsentry. Scenarios tagged[thread-notifications]. - FIXED (sending a message did not mark earlier messages read): spec, "When the user updates
their read receipt (either by using the API or by sending an event), notifications prior to and
including that event MUST be marked as read". A non-state event the user sends now reads its own
thread up to itself (
sync::read_markers_for). State events do not count, so the membership update a profile change sends to every room marks nothing read. The position is not stored or sent as a receipt (ADR-0170). Scenarios tagged[own-event-read].
0.12.29¶
- DOCS: reconcile security guarantees, master-key lock failure policy and its superseded ADR, implemented endpoint status, runtime lock scopes and sync capacity with the current implementation. No runtime security policy changes.
- DOCS: correct application-service YAML registration support, autonomous delivery
retries, PostgreSQL migration-role enforcement, push budgets and rate-limit
defaults (including the stable sliding-sync generic-tier exception). Document
the SQLite worker's broader hydration and remaining signing-buffer lock limits.
Include
listeners.max_queued_connectionsin the configuration example. - DOCS: add backend-specific backup and restore procedures with validation steps, and align version-bump timing with the branch-creation rule.
- DOCS (review fixes): the README master-key step now uses mode
0400and ownermerovingian, as the startup secret-file check requires (0600was rejected). The restore procedure names theserver.namekey, notserver.server_name. The manual notes that the Docker image needs a master key mounted at/etc/merovingian/master-key. - TESTS: the example-config smoke tests (
server-check-config,server-example-config,server-example-config-summary) now provision an owner-only master key and point the copied example at it. The example enablessecurity.secrets.master_key_file, and a configured key that is missing fails--check-configand--dry-run, which failed every CI build job.scripts/validate-phase1-config.sh, which the Linux CI jobs run after the test suite, does the same for its--dry-runof the example (it exited 66,config_io_error).
0.12.28¶
First wave of fixes for the 17 October 2026 Matrix v1.19 conformance audit
(docs/conformance-audit-report-2026-10-17.md): the three findings that lost delivery work
while reporting success (R01–R03), plus two to-device defects found while fixing R02.
- NEW (
PeriodicJob):homeserver::PeriodicJobruns one job on a dedicated thread, once at start, again onwake(), and otherwise after the delay its last run returned; the job gets ahomeserver::StopFlagit polls between units of work. One thread per job, so a queue drained by a job has one consumer. The thread starts only instart()(ADR-0082). It is built onstd::thread, notstd::jthread/std::stop_token, which the libc++ of FreeBSD and OpenBSD lacks;docs/coding-rules.mdnow forbids both (ADR-0167). Scenarios tagged[periodic_job]. - FIXED (R01, appservice events skipped after an outage): the appservice cursor held one
pending event; when its retry failed the triggering event was not queued, and when the
appservice recovered every event in between was skipped. Nothing retried once room traffic
stopped, and delivery ran on the request thread with the runtime lock released, so two requests
could race on one cursor. A request now only queues:
enqueue_appservice_deliverywrites anappservice_outboxrow (migration022, interest decided at event time) and wakes the one sender,drain_appservice_queues, run by aPeriodicJobfrommain.cpp. A transaction's ID and event are written to the cursor before it is sent; on acknowledgement the cursor and the outbox row commit together. Failures back off from 1 s to 5 min per appservice. The outbox primary key is the only dedupe: there is no "already delivered" threshold, because events in different rooms commit out of stream order.deliver_federation_push_notificationsnow takes the runtime lock (its caller did not hold it). ADR-0163; scenarios tagged[r01]. - FIXED (R02, a failed to-device enqueue answered 200):
PUT /sendToDevicediscarded the enqueue result, recorded the transaction and returned 200, so the client's retry was replayed without delivery. Remote EDUs are now queued first and a queueing failure answers 500; then every local message and the transaction record commit in one backend transaction (enqueue_to_device_messages_with_client_txn), and a failed commit answers 500 with nothing recorded, so a retry with the same transaction ID is delivered. Withsecurity.federation.enabled = falsea remote recipient is discarded like an unknown local one rather than failing the request for ever. ADR-0164; scenarios tagged[r02]. - FIXED (R03, federation PDUs lost after a 200): a PDU whose
prev_events/auth_eventscould not be backfilled, or whoseprev_eventhad no state group, was dropped while/sendanswered 200 with no error for it, and the sender never resent it. It is now retained indeferred_pdus(migration023; at most 100 per room, 1000 in total, 16 MiB, 24 h, 10 retries) with the new statusPduIngestionStatus::deferred, which/sendreports as handled; a PDU that cannot be retained gets anerrorin thepdusmap.DeferredPduRetryJobretries due PDUs through the federation PDU sink with back-off from 30 s to 1 h, and the arrival of an event a retained PDU names makes it due at once. PDUs held until an outbound join commits are also reported asdeferred. New audit eventfederation.pdu_deferral_expired. ADR-0165; scenarios tagged[r03]. - FIXED (to-device message IDs collided across senders): an outbound
m.direct_to_deviceEDU used the client transaction ID as itsmessage_id, which is unique only per user, so two local users with the same transaction ID had the second message dropped as a replay by the receiver (and an ID over 32 codepoints was invalid). The ID is now derived from the sender, event type and transaction ID (derive_direct_to_device_message_id): stable across a client retry, distinct across senders, 24 characters. Inbound, the replay window is keyed on(origin, sender, message_id), the EDU is stored in one backend transaction (enqueue_to_device_messages) and its ID recorded only after the commit, so a failed store no longer turns the origin's resend into a dropped replay. ADR-0166; scenarios tagged[message-id]. - FIXED (
line-endings-toolingtimeout): the test rangit ls-files --eolbefore each of its four cases, about 6.5 s each on a WSL/mnt/ccheckout, so under a parallelmeson testit exceeded Meson's 30 s default timeout. It now scans once per class (setUpClass). - DOCS: the audit report and its remediation status are recorded in
docs/conformance-audit-report-2026-10-17.md.
0.12.27¶
Fixes the intermittent createRoom 500 behind the flaky sync conformance scenario, makes
Unix-consumed files check out with LF line endings on every platform, and fixes the two
failures behind the OpenBSD CI job's red runs (7 of the last 9 before this release): a
retried federation transaction processed twice, and an integration test hung in connect().
The federation worker's room reloads now also read one consistent snapshot.
- FIXED (createRoom 500 on a room ID collision): in room version 12 the room ID is the
create event's reference hash, and a v12 create event has no
room_idand noprev_events, so twocreateRoomrequests from one user with the same creation content in the same millisecond composed byte-identical create events, derived the same room ID, and the second failed on the duplicate room row with a 500 logged only at debug. This is what made the conformance scenario "GET /sync returns spec-shaped room category objects" fail intermittently (5 of 40 runs in a release build).create_roomnow stamps each create event strictly after the previous one this runtime composed (HomeserverRuntime::last_create_event_ts_ms), reserved before the policy-server hook releases the runtime lock, and steps past any timestamp whose room ID already exists, as after a restart with the clock stepped back (ADR-0156). Every 5xx fromcreate_room(createRoom,/upgrade, the local router) is now logged once at error asroom.create.failedwith its reason. New test seamHomeserverRuntime::test_frozen_event_clock_msfreezes the event clock; the new[create-room-collision]conformance and integration scenarios use it to reproduce the collision on every run. The runtime's move constructor and assignment now carry both new fields. A further integration scenario, "a room creation that fails on the server leaves no room behind" ([create-room-failure]), starts a runtime that cannot sign and checks the 500 leaves no room row, member or timestamp reservation. The size-limit conformance scenario now also sendscreation_contentthat passes the request pre-check but makes the signed v12 create event exceed 65536 bytes, and gets 400M_TOO_LARGEwith no room created. - FIXED (CRLF working trees): a checkout made by Windows Git with
core.autocrlf=truewrote CRLF into every text file, because.gitattributescovered only*.sh,*.ps1and*.cmd. A full WSL run from such a checkout failed the[platform][iso8]systemd unit scenario (it matches"\nRestrictAddressFamilies=…\n"), and a package built from it would have shipped CRLF unit files and maintainer scripts..gitattributesnow sets* text=auto eol=lffor the whole tree; Windows-only.ps1and.cmdkeep CRLF. The index was already LF, so no stored file changes (ADR-0158). New tooling testtests/tooling/test_line_endings.py(line-endings-tooling): every tracked text file carrieseol=lf, the Windows scriptseol=crlf, and nothing is stored with CRLF; RED with 2 failures before the change.docs/dev-environment.mdgives the one-off re-checkout for existing working trees. - FIXED (OpenBSD CI, federation transactions): a retried
PUT /send/{txnId}was processed a second time when its room moved between the federation worker and main. Matrix v1.19 has the sender retry one txnId until it receives 200, so a completed transaction must not run again; its PDUs were caught by event-ID dedupe, but its EDUs (typing, receipts, to-device messages) were delivered twice. Each process kept its own dedupe ring, and the proxy sends a room's transactions to main while that room's worker reload has failed, so a transaction the worker completed was new to main, and one main completed during a fallback was new to the worker once the room recovered. Main's ring is now the record: the proxy records each/sendthe worker answers with 200 there, and hands any/sendalready in it to main's handler, which answers it as a duplicate after its usual rate limit (ADR-0160). New public helpersfederation_send_transaction_id,federation_transaction_completedandrecord_completed_federation_transaction(federation/inbound_request.hpp); main's own handler appends through the last one. This was the failure in "main key-admission overload survives real worker socket HTTP and unchanged transaction retry" (3/3 on PR #510,cache_misses 3 == 2,raw_calls 2 == 1,edu_deliveries 2 == 1). Tests:[txn-dedupe]intest_federation_worker_flow.cppforces the room's reload to fail by moving the SQLite file aside, then retries the same signed bytes both ways round (worker first, then main; main first, then the recovered worker). RED on Linux:edu_deliveries 2 == 1, the CI failure reproduced off OpenBSD. - FIXED (test, postgres-integration): "PostgreSQL migrations execute as the migration
role, never as the login role" failed whenever it was the first scenario to open a fresh
database (Catch2 seed 1273323633 on PR #510:
unable to migrate PostgreSQL schema, thenunable to assume PostgreSQL migration role). Its first two cases assume the schema is already current, but nothing made it so. The login role bootstrapped v1 and owned its tables, and the pending migrations then ran asmerovingian_migration, which cannotALTERthem. The scenario's setup now brings the schema current as its owner and hands ownership to the migration role, asprovision-roles.sqlleaves a deployment, and both cases print the open's refusal reason. Reproduced locally by replaying the seed's first 30 integration scenarios against a fresh PostgreSQL 16 cluster with CI's roles; passes after. - FIXED (OpenBSD, unveil): a failed
unveil(2)left the process half-sandboxed. The OpenBSD runtime profile calledunveilpath by path and stopped at the first failure, but the first call had already hidden the rest of the filesystem, and no call can be undone. OpenBSD has no/var/lib, so unveiling/var/lib/merovingianfailed withENOENTafter/usrand/etc/merovingianwere unveiled. The federation worker applies this profile after loading its store whenfederation.worker.apply_hardening=false, so every later room reload failed at open (room_snapshot.read_failed stage=open, 6 of 6 on PR #510's OpenBSD run). This, not the SQLite race below, is why the room fell back to main on OpenBSD.openbsd_unveil_rules()now plans every call first, and none is made unless every path, or the directory it would be created in, exists (ADR-0162). Main still refuses to start when the control fails. Tests:[unveil]intest_runtime_hardening.cppchecks the planned calls and that a missing/var/libplans none. The OpenBSD-only apply code was syntax-checked off OpenBSD with stubunveil/pledgedeclarations; it has not run on OpenBSD. - FIXED (SQLite, read-only connections): a read-only read could fail when one of
main's commits landed between SQLite's two hot-journal checks. SQLite checks that the
journal exists and then that no process holds RESERVED; a writer that deletes its journal
and unlocks in between makes its own commit look like a crash (ticket #3883). A read-write
connection settles that under EXCLUSIVE; a read-only one fails at once with
SQLITE_READONLY_ROLLBACK, skipping the busy handler. The open-time pragmas,prepareand every read's first step now retry that one result every 10 ms for up to 2 s (ADR-0159). A journal that is really hot still fails the read and is left for main's read-write connection to roll back. This covers the room reload, the worker's startup load, and media blob and audit reads. A failed room snapshot logsroom_snapshot.read_failedwith its stage and extended SQLite code, and a connection whose pragmas fail logsconnection.rejectedwith its code. That log line is what showed the OpenBSD reloads failing at open, which turned out to be theunveilbug above rather than this race. Tests:[sqlite-false-hot-journal]intest_sqlite_security_policy.cppputs a journal that looks hot beside the database and removes it 200 ms later, as the committing writer does; the room snapshot and the worker's store load now succeed, and a journal that stays still fails within the budget and is left untouched. RED 2 failures. - FIXED (OpenBSD CI, test harness): "Federation socket HTTP refuses NUL path components
without disconnecting or changing room data" and its proxy twin hung until meson killed the
integration binary at 1800 s (main at b200c912; PR #510 run 38965086645). The reported
"fatal error condition" was that SIGTERM, not a crash. The last passed assertion was the
setsockoptjust before a blockingconnect()to the test's own listener (backlog 128, 6 to 9 connections in), so the test thread slept inconnect()for about 29 minutes, far past TCP's 75 s connection-establishment timer. Production code makes no rawconnect()call. Every integration-test loopback connect (12 call sites in 10 files) now goes throughmerovingian::tests::connect_within(tests/integration/bounded_connect.hpp): a non-blocking connect polled in 100 ms slices against a 5 s deadline, which restores the descriptor's blocking mode. A missed wakeup now costs one slice, and a connect that cannot complete fails one scenario. Not reproduced off OpenBSD. Tests:[bounded-connect]in the newtest_bounded_connect_flow.cpp.docs/testing-standards.mdgains "Socket calls in integration tests". - FIXED (worker room snapshot): the federation worker's room reload could serve a room in a
state main never committed.
load_room_snapshot_implread rooms, membership, invites, events, current_state, event_edges, event_auth and event_signatures as eight separate statements, each its own read (SQLite autocommit; PostgreSQL READ COMMITTED). A commit by main between two of them mixed rows from before and after it: for example, current state naming an event the snapshot did not hold. The worker answers/state,/state_ids,make_joinand/eventfrom this snapshot. Each room reload now reads in one transaction: a deferredBEGINon SQLite (SqliteReadTransaction, whose first read holds SHARED to the end, so main's commit waits) andBEGIN ISOLATION LEVEL REPEATABLE READ READ ONLYon PostgreSQL (ADR-0161). ADR-0159's retry of a journal that only looks hot still runs on the first read inside the transaction. The worker's whole-store startup load stays per statement, so on a large store it never holds SHARED for longer than main's 5 s busy timeout.detail::load_room_snapshot_from_sqliteanddetail::load_room_snapshot_from_postgresqlgain an optionalRoomSnapshotReadHook, a test seam called after each table's read; production passes none. Tests:[room-snapshot-consistency]intest_sqlite_security_policy.cppandtest_security_audit_postgresql_reload_flow.cppcommit a new event and make it current state between the events and current_state reads, then check the snapshot's state names only events it holds. RED on both backends (PostgreSQL run against a local server throughMEROVINGIAN_TEST_POSTGRESQL_URI).
0.12.26¶
Completes the 29 September 2026 security audit: every remaining low-severity finding is fixed, so all 101 findings (1 critical, 23 high, 31 medium, 46 low) are now closed. Entries marked PARTIAL or IN PROGRESS further down record intermediate steps on this branch; the FIXED and ACCEPTED entries at the top supersede them. Full WSL suite at the final commit: 57 OK, 0 failed, 0 timed out.
- FIXED (PR #509 CI, SQLite locks): opening a SQLite connection released another
connection's write lock. Closing any descriptor of a file drops every POSIX lock the
process holds on it, and
open_sqlite_connectionopened and closed the database file itself on every connection (the DB-10 owner/type/mode check on the read-write path, the ISO-4 WAL check on the read-only one). Another process then found a journal with no RESERVED lock and took it for a hot one: on OpenBSD the federation worker's read-only room reload failed, the proxy fell back to main, and a retried transaction was processed twice ("main key-admission overload survives real worker socket HTTP and unchanged transaction retry"); a read-write process could have rolled back a transaction still in progress. An existing file is now checked by name (fstatat,AT_SYMLINK_NOFOLLOW) and opened only to tighten a loose mode, and the read-only WAL check reads the header through SQLite's own descriptor (SQLITE_FCNTL_FILE_POINTER) before any statement runs (ADR-0157). Tests:[sqlite-locks]intest_sqlite_security_policy.cpp(a forked process checks the RESERVED lock survives a read-write and a read-only open; a WAL database is still refused read-only without creating-shm); RED 2 failures. The same CI round fixed two build breaks: gcc's-Wnull-dereferenceat-O2(Fedora, RHEL RPM builds) onGatewayCircuitBreaker::record's eviction, now guarded likeThumbnailCache's, and the startup hardening gate's GNU-only\|basic-regex alternation, which OpenBSD's grep reads literally (grep -E; tests intest_startup_hardening_gate.py). The new test declares its lock probe asstruct flock, since the BSDs also declare aflock()function andflock{}did not compile there. - FIXED (PR #509 review, CI): the branch did not build outside WSL/gcc. A Catch2
CHECKon astd::tuplecomparison failed to compile under clang with libstdc++ 14 (test_worker_relay_signature_flow.cppnow compares a struct);postgresql_store.cpplacked<netinet/in.h>/<sys/socket.h>on the BSDs; a Linux-only test helper left a parameter unused elsewhere; andobservabilitycalleddatabase::bounded_utf8, a dependency against the module order (database depends on observability) that broke the fuzz link.bounded_utf8andmax_audit_field_bytesnow live inobservability/bounded_text.hpp, and the logger fixture's workaround is gone. The docs build failed on a broken anchor in ADR-0132. The new package gates exposed two packaging bugs:packaging/deb/conffileslisted the directory/etc/merovingian, so dpkg unpacked it as/etc/merovingian.dpkg-newand the file inside could not be created, and theDockerfileusedaddgroup/adduser, whichdebian:trixie-slimdoes not ship (nowgroupadd/useradd). The.debalso stopped shipping the vendored sqlite3 archive and headers (--skip-subprojects, as every other package script). Tests:test_packages_workflow.py(conffiles name files the package installs, the image creates its user with tools its base ships, no subproject install); RED 3 failures. The first CI run on these fixes found three more: the.debpostinst runsadduserbut the package did not depend on it, which container base images do not ship (Depends: adduser, tooling test added, RED 1); gcc at-O2flagged the thumbnail cache's eviction loop under-Werror=null-dereference(the iterator is now checked againstend()); and six tests added on this branch usedstd::jthread, which the FreeBSD and OpenBSD libc++ lack (nowJoiningThreadsorstd::threadwith an explicit stop flag). The CRY-4 scenarios that read kernel lock state from/proc/self/smaps, or dropRLIMIT_MEMLOCKto make a lock fail, are skipped under AddressSanitizer, whosemlockinterceptor locks nothing and never fails. The ISO-5 decoder-stderr scenario compares stderr with/dev/nullonly on Linux: NetBSD's/dev/fd/2is a static device node without fdescfs, so-efcould not compare it; the write and the empty parent capture still prove the property everywhere. - FIXED (PR #509 review, auth): the token-key cache dereferenced an empty optional when
the first master-key open failed and a second open by path succeeded, and every
authenticated request copied both token keys into fresh locked allocations, which could
exhaust
RLIMIT_MEMLOCKunder load and turn auth into 503s. The cache moved intocrypto::cached_token_hmac_keys: one open per lookup, keys derived only from that open, shared (not copied) on a hit, and an unreadable file logged once per outage rather than per request. Copying a moved-fromSecureKeyBytesnow throws instead of yielding a valid all-zero key. Tests: newtest_crypto_token_key_cache.cpp([cry4][token_key]); the[cry6-lock]scenario now asserts that issuance with no locked-memory budget left succeeds from the already-locked cached keys, where it used to expect a 503 from the copy. - FIXED (PR #509 review, federation):
GET /backfill/{room}?v=%00threw out ofparse_backfill_query(the path decoder throws on NUL and the transport's guards stop at?), dropping the connection instead of answering; the parser now refuses a NUL anywhere in the target (ADR-0137). Everysecurity.federation.deny_ip_rangesentry must now be a well-formed address or CIDR range: the boundary treats a malformed entry as denying every address, so a typo such as/33silently stopped all federation. A transaction's sender-key pre-pass no longer runs more resolutions at once than the runtime's in-flight cap, which admission refuses rather than queues: with four worker shards (cap 4) any transaction naming five or more cold sender servers used to deny itself a 503 (federation::sender_key_prepass_parallelism)./send_joinstate verification called the raw key resolver up to 1024 at a time, outside every FED-10 budget, so a resident server could name thousands of servers and drive a burst of fetches; it now takes main's shared in-flight slots and honours the server/key failure cache throughfederation::resolve_join_state_key, waiting for a slot (until the join timeout) instead of being refused, and is not charged a per-source rate, since a refused key would drop a member event from the joined room's state (ADR-0155). Tests:[nul]backfill scenario,test_config_parser.cppdeny-range scenario,[fed10-shards]parallelism scenario,[send_join][fed10]shared-cap and failure-cache scenarios (RED: peak above the cap; a failed pair fetched twice). - FIXED (PR #509 review, media): after a decode, the record was checked before the media
policy, whose policy-server round trip releases the runtime mutex, so a quarantine landing
during it was missed and the thumbnail was served and cached; the policy is now asked
first and the record checked last.
security.media.thumbnail_cache_size=0failed validation although the manual and ADR-0152 say it disables the cache. Cache keys clamp each axis to the worker's 4096 maximum, sowidth=5000, 5001, ...no longer fill the cache with copies of one thumbnail. The thumbnail cache size and per-user decode cap are restart-required: nothing rebuilt them on reload. Tests: integration[med-4-policy-order]; unit[med4]cache-size and dimension scenarios; reload policy. - FIXED (PR #509 review, push): the gateway circuit counted 5xx answers and was keyed by
the raw host, so one user's pusher with bad data on a shared gateway could close it to
every user for the cooldown, and case or trailing-dot spellings had separate circuits.
Only failures to reach the host (DNS, connection, TLS, timeout) count now
(
push::gateway_circuit_outcome), and the key is the case-foldedhost:port. Arejectedlist now removes only a pusher whose own pushkey it names; any non-empty list used to delete the requesting pusher. ADR-0151 amended. Tests:[out3]classification, key and rejection scenarios; integration: an unreachable host is skipped after three failures, a host answering 500 is still contacted, a rejection of another pushkey keeps the pusher. - FIXED (PR #509 review, database): SQLite refused any symlinked ancestor directory, so
a data directory linked to another volume stopped the server starting; existing
directories are now followed, the database file itself must still not be a symlink, and
everything below a directory the server creates is opened without following links.
SQLite is handed the database's real directory, since
SQLITE_OPEN_NOFOLLOWrefuses a link anywhere in the name it is given.ConnectionPool::acquireleaked its slot if opening or probing a connection threw, and a wait for a slot has no timeout. Tests:[db10]symlinked data directory scenario; newtest_database_connection_pool.cpp([db7][pool]). -
FIXED (PR #509 review, operations): a refused
--log-filenow says so on the console (SingleLog::set_log_file_pathreturns whether the file opened). The startup hardening gate also requires the federation worker to be spawned and still running a few seconds later (--worker-settle, default 3 s), and rejects a non-numeric--timeout, port or settle time. ADR-0142's verification section contradicted its status and is rewritten; internal agent and process IDs are removed from this changelog and the audit report. Tests:test_startup_hardening_gate.py(worker dies, worker never starts, non-numeric timeout),[iso6]refused-path scenario. -
FIXED (DB-7): every persisted write opened a fresh database connection (an SQLite open with its no-follow checks, or a PostgreSQL connect, TLS and
SET ROLE), usually with the runtime mutex held. Writes now lease connections from a per-storedatabase::ConnectionPoolcapped atdatabase.pool_size(16), which is now restart-required; a write waits for a free connection rather than opening another. A PostgreSQL connection takes the runtime role once and is reused only while idle with nothing to read on its socket, so one the server has closed (restart, idle reaper) is replaced instead of failing a write; reads and migrations are unchanged (ADR-0154). Also fixed:PersistentStore's copy constructor and assignment did not copypostgresql_runtime_role, so a copied store would have written as the login role; and the PostgreSQL signing-key restart test wrote its URI file group-readable, which the ISO-7 reader rightly refuses (it only runs withMEROVINGIAN_TEST_POSTGRESQL_URI). Tests: newtest_security_audit_write_pool_flow.cpp([db7], 64 concurrent writers on real SQLite and PostgreSQL with a limit of four, then every pooled backend killed). RED: the pool API did not exist; a first version that trustedPQconsumeInputlost 4 of 64 writes after the kill, which the socket-readiness check fixed. -
ACCEPTED (FED-12, DB-6, DB-8, DB-9, OPS-2, OPS-4): implemented earlier on this branch (entries below) and now accepted: each fix was checked against its finding, and the regressions re-run at
cc7e1ee7: unit[fed-12-live-context]49 / 2,[ops2]101 / 2,[ops4-negative-lifetime]20 / 1,[ops-4-upper-bound]146 / 1,[db6]9 / 3,[db8]48 / 2,[db9]6 / 1; integration[fed-12-origin-binding]109 / 1,[ops2]44 / 2,[db6]2909 / 15; conformance[ops-4]223 / 1. ADR-0137, ADR-0138, ADR-0139 and ADR-0142 are accepted. The audit report now lists every low finding as fixed. -
FIXED (FED-10): the last part of FED-10, aggregate admission across worker shards. Each federation worker shard had its own copy of the key-resolution budgets (
key_resolution_max_in_flight,key_resolution_per_ip_rate), sofederation.worker.shardsworkers allowed that many times the configured outbound key fetches. A worker now starts with its share (federation::shard_key_resolution_budget: the limit divided by the shard count, rounded down, at least 1) throughRuntimeStartOptions::key_resolution_shards; main keeps the whole budget (ADR-0153). Tests: newtest_security_audit_fed_10_shards.cpp([fed10-shards]). RED: the split function and the start option did not exist, so every shard started with the full budget. -
FIXED (MED-4): thumbnails were decoded with the lock released (0.12.20) and local ones revalidated after the decode (ADR-0145), but every request forked the decoder again, one client could run any number of decodes at once, and a remote thumbnail was not revalidated. New
media::ThumbnailCachekeeps generated thumbnails in memory (security.media.thumbnail_cache_size, 64 MiB, least recently used first out), served only after the source record is checked as servable. Newsecurity.media.thumbnail_max_concurrent_per_user(2) caps decodes in flight per user (or client address when unauthenticated); beyond it429 M_LIMIT_EXCEEDED. A remote thumbnail now carries the cached local record its bytes were admitted as and is revalidated like a local one, and every thumbnail re-asks the media policy after the decode (ADR-0152). Also fixed:make_runtime_media_config's positional initializer put the thumbnail timeout's10intothumbnail_worker_fd, so on FreeBSD a failed worker pre-open left fd 10 to befexecved; it is now-1. Tests:[med-4-cache],[med-4-concurrency]and[med-4-remote]intest_media_thumbnail_revalidation_flow.cpp(real worker and gate) and newtest_media_thumbnail_cache.cpp. RED: with the worker gone a repeated thumbnail was 404, a third concurrent decode for one user entered the decoder, and a remote thumbnail was served after its cached record was quarantined. -
FIXED (OUT-3): one user's push gateways could still tie up delivery: registrations were unbounded and a gateway host that stalled or failed was contacted again for every event, holding a delivery slot for its full timeout. New
server.push.max_pushers_per_user(20):POST /pushers/setrefuses a new pusher beyond it with 400M_INVALID_PARAM(updates and deletes still allowed). Newpush::GatewayCircuitBreaker: afterserver.push.gateway_failure_threshold(3) consecutive transport failures, timeouts or 5xx responses, a host is skipped forserver.push.gateway_cooldown_seconds(300), then probed once per cooldown; skipped deliveries logpush.delivery.circuit_open. The per-recipient in-flight cap already existed and now has a test. ADR-0151 supersedes ADR-0029's "registration stays unbounded". Tests: three[out3]scenarios intest_push_delivery_flow.cppand newtest_push_gateway_circuit_breaker.cpp. RED: a 21st pusher was accepted and all five notifications reached a gateway answering 500. -
FIXED (ISO-7): the remaining two re-opens of a secret are gone. The TLS private key was checked at startup by path and then loaded by OpenSSL from the path again (
SSL_CTX_use_PrivateKey_file);make_tls_server_contextnow reads it through one validated descriptor (O_NOFOLLOW, regular, owned by this user, mode 0400) into locked memory and gives OpenSSL a memory BIO. The signing-secret-box and token-HMAC caches took the master key file's identity from one open and its bytes from another, so a swap in between could cache a key from one file under another's identity; newcrypto::open_master_key_filereturns the descriptor and identity together, and both caches read the bytes from it. Test fixtures now write TLS keys 0400, as production already required. Tests: newtest_security_audit_iso_7.cpp([iso7]). RED: a 0640 key and a symlinked key were both loaded; the single-open master-key API did not exist. -
FIXED (EVT-12 (d)): room versions 3-5 refused a float power level, which
rooms/v3.mdallows ("exponential notation is applied ... the value is truncated at the decimal point"). NewRoomVersionPolicy::power_levels_accept_floats(v3-v5) andevents::PowerLevelEncodingreplace theallow_string_valuesflag, so the auth rules, the state resolver's power ordering, redaction judgement and push all read integers, integer strings (v1-v9) and truncated floats (v1-v5) by the room's own version. Infinity, NaN and values beyond int64 are refused. Hashing is unchanged: Canonical JSON defines no float encoding, so such an event still cannot be parsed for its event ID or content hash (ADR-0150). Tests: three[evt12]conformance scenarios intest_event_auth_rules.cpp(v5 truncation, v41.0E2honoured, v6 refused); the unit characterization intest_event_auth_rejected_fallback.cppnow asserts the new contract. RED: both v5 acceptance cases were refused. -
FIXED (DB-4): a ban could commit while the banned user's
membershiprow still saidjoin, and every restart rebuilt the room's roster from that row, so the user could read/joined_membersagain. Every path that stores anm.room.memberevent wrote the event and itscurrent_staterow in one transaction and the row and invite metadata in later ones. The row and invite change are now adatabase::MembershipProjectioncommitted in the event's own transaction on both backends: local membership changes (persist_composed_event), the federatedjoin_room, inboundsend_join/send_leave/send_knock,/inviteand PDUs over/send. A federated leave, and/invitefor a room this server holds, write row and invite together withstore_membership_projection. At startuphydrate_local_databasereconciles the rows from current state before building the roster: a row is rewritten when it is missing, saysjoinwhile the current member event does not, or is older than that event; a newer row (a federated leave or an/invitewhose event has not arrived) is kept. Inbound PDUs no longer log and drop a failed membership write after accepting the event. ADR-0149 (refines ADR-0126); no schema change. Tests: newtest_security_audit_membership_atomicity_flow.cpp([db4]: a tampered SQLite store regains no access after a restart; a SQLite trigger that refuses bob's row leaves no ban event or state; the PostgreSQL variant, gated onMEROVINGIAN_TEST_POSTGRESQL_URI, with a real trigger) and newtest_security_audit_db_4.cpp(reconciliation rule and projection atomicity on the in-memory store). RED: after a restart bob's/joined_membersreturned 200 and alice's roster listed him, and with his row refused the ban event and its current state were still durable (5 failed assertions). Review follow-up: the reconcile first ran for every load profile, so the federation worker's open also rewrote rows and drew stream positions from its own counter; on PostgreSQL that durably wrote main'smembershiptable. It now runs only forTableLoadProfile::full(main), like the redaction reconciler; main reconciles before it spawns the worker. New[db4-worker]scenarios (SQLite and gated PostgreSQL); RED: the PostgreSQL worker open rewrote bob's row toban, and the SQLite worker open rewrote it in memory and allocated a stream position (3 failed assertions). -
FIXED (OPS-5): the Docker image and the deb and BSD packages built at Meson's default
debugbuildtype (-O0), so_FORTIFY_SOURCEwas never defined and every shipped server refused to start at its startup hardening gate.scripts/build-deb.sh,build-freebsd-pkg.sh,build-netbsd-pkg.sh,build-openbsd-pkg.shand theDockerfilenow pass--buildtype=release;meson.buildkeeps thedebugdefault for development builds and warns at configure time when hardening meetsoptimization=0(ADR-0148). TheDockerfilepinsdebian:trixie-slimby index digest in both stages, installslibpq-dev/libpq5and the other libraries the binaries link (with trixie'st64runtime names), shipsmerovingian-db-migrate,merovingian-fed-workerandmerovingian-thumbnail-worker, and fails its own build iflddfinds an unresolved library; a new.dockerignorekeeps local build directories out of the context. Newscripts/check-startup-hardening-gate.shboots a packaged server against a throwaway SQLite config and passes only when every hardening check reportsenabled, the count matches, and the server exits 0 onSIGTERM.packages.ymlruns it against the installed.debin thedebanddebian-pkgjobs (asmerovingian,setpriv --bounding-set=-all) and inside the image in a newdocker-imagejob (--cap-drop=ALL --security-opt=no-new-privileges). The BSD package jobs do not run it yet. Tests:test_packages_workflow.pygains six scenarios (buildtype per script, digest pins, Dockerfile build flags and libraries, shipped workers, CI wiring); newtest_startup_hardening_gate.py(registered asstartup-hardening-gate-tooling) covers the gate's eight decisions against a fake server. RED: 18 packaging subtests failed and the gate script did not exist. Run against a real--buildtype=release -Dhardening=trueserver (non-root, empty bounding set via a user namespace) the gate first failed because the master key shared the config's directory, which the federation worker's Landlock guard rightly refuses; config, database and key now get separate directories (new scenario, RED then GREEN). It then passed with all 15 checksenabledand a cleanSIGTERMexit, and against adebugbuild it failed onFORTIFY_SOURCE=unknownandcompiler hardening=unknown, the audit's reproduction. Full hardened WSL run at the first commit: 56 OK, 1 Fail (conformance-tests: "GET /sync returns spec-shaped room category objects" gets a 500 fromcreateRoomwith an invite; it fails 2 of 20 times run alone and is unrelated to this change, which touches no C++), 0 timed out. - FIXED (ISO-8): the federation worker's seccomp profile argument-checks
socket():AF_UNIX,AF_INETandAF_INET6are allowed,AF_NETLINKanswersEAFNOSUPPORTso glibc'sgetaddrinfoaddress-sorting probe falls back, and any other family (AF_ALG,AF_PACKET,AF_VSOCK, …) kills the worker;socketpair()isAF_UNIXonly. The systemd unit addsRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6,RestrictNamespaces=,ProtectKernelTunables=,ProtectKernelModules=,ProtectKernelLogs=,ProtectControlGroups=,ProtectClock=andProtectHostname=. The docs now say why main's PostgreSQL role must not usepeerauthentication. RED:[iso8]failed 5 of 35 assertions (healthy families passed); GREEN passes 60 / 2,[seccomp]367 / 15 and[iso2]30 / 1, in a fresh full WSL run of 56 OK / 0 failed / 0 timed out. ADR-0147. - FIXED (ISO-6, log file):
--log-fileis opened by the new header-onlyobservability::LogFileSinkwithO_NOFOLLOW|O_CLOEXEC|O_NONBLOCK, used only when it is a regular file this process owns, then made 0600 and truncated (as before). A symlink, FIFO, device or foreign file is refused and the logger writes no file output. RED: the logger wrote through a symlinked log path (1 of 35[iso6]assertions failed); GREEN passes[iso6]35 / 6,[iso6_defaults]32 / 2 and[logger]156 / 8 in the same full run. ADR-0146. Together with the umask, package-script and SQLite-creation changes already on this branch, ISO-6 is closed. - ACCEPTED (ISO-4, DB-10): reviewed the checkpoint's SQLite changes. The worker
opens main's SQLite file read-only, refuses a WAL-mode file, and its Landlock grant
on the database directory is read-only; main creates missing directories 0700 and
the database 0600 without following symlinks, tightens an existing file it owns to
0600, and sets
secure_delete=ONandtrusted_schema=OFF. The threat model's stale "read-write" Landlock wording is corrected. Residual: the database path is reopened by name after the descriptor-relative checks, so a writable parent directory still allows a swap between them.[iso4]15 / 2 and[db10]15 / 2 pass in the same full run. - ACCEPTED (MED-4, local thumbnail revalidation): local thumbnail requests
own the source media/storage identity across unlocked blob reads and decoding.
Successful worker output is checked against that record's current servability,
storage binding and legacy visibility before publication or served accounting;
a quarantine or removal wins with 404 even when an available alias shares the
same blob. A real SQLite/socket regression gates a child before it execs the
actual worker, proves healthy ungated/gated output and concurrent
/versionsand admin operations, then checks durable quarantine/removal and alias safety. Genuine RED passes 249 / 255 assertions, failing the six expected PNG-disclosure checks across both actions. Normal GREEN passes 255 / 1 inside a fresh full WSL run of 56 OK / 0 failed / 0 timed out. Output caching, per-user decode admission and remote source identity remain separate MED-4 work. See ADR-0145. -
ACCEPTED (MED-7, bounded admission repair): federation media downloads now consult the existing media-policy decision before repository resolution or blob reads. A denial returns 403 with no media bytes or content type, and the federation dispatcher returns its existing JSON error rather than a multipart success. Real SQLite integration covers persisted admin denial, genuinely signed requests, an unaffected upload and recovery after rule removal; a second scenario checks the configured unavailable policy hook and explicit fail-open setting without an injected callback. RED passed 116 / 129 assertions, failing the 13 expected refusal checks; final normal GREEN passes 132 / 2. Related media integration passes 371 / 9, locking 146 / 7, federation media conformance 12 / 1, client policy 41 / 3 and trust-safety units 100 / 13. Independent review passes; the fresh normal full suite completes 56 OK with every return code zero and 1,026 frozen files unchanged. Live remote-policy HTTP responses and strict policy revocation during an admitted request are not established. This accepts the bounded admission repair, not the whole audit or release. See ADR-0144.
-
FIXED (MED-8): the thumbnail worker checks a crop's actual fill against the existing effective pixel budget in 64-bit arithmetic before narrowing or allocating. Zero still uses the 4,096,000-pixel worker ceiling; a larger request cannot raise it. Refusal uses the existing empty
too_largeframe (413 fromgenerate_thumbnail), not an allocation failure or worker crash. Main prepares an emptyinternal_errorframe after hardening and before input, then catches unexpected C++ exceptions without allocating a replacement error string. A safe real-worker RED passed 115 / 163 assertions, failing only the 48 expected crop checks with all healthy controls passing. Normal GREEN passes 201 / 3; related thumbnail integration 776 / 10, ISO-5 isolation 554 / 4, thumbnail units 35 / 8 and hardening units 21 / 10. The original 64 GiB amplification inputs run only as first-GREEN coverage. The fresh full WSL run completes 56 OK / 0 failed / 0 timed out, with the MED-8 tag re-run at 201 / 3; separate exception fault injection and broader thumbnail geometry/upscaling conformance are not claimed. See ADR-0143. - FIXED (HTTP-7): permissive JSON parsing checks integer bounds before converting
unsigned values to signed values; values outside the canonical integer range are refused
with
integer_out_of_rangeinstead of wrapping. - FIXED (EVT-10): an invite with any
third_party_inviteproperty enters the third-party authorization branch; malformed/non-object values cannot fall through to ordinary invite authorization. - FIXED (EVT-11): legacy room versions honor string-valued thresholds in the nested
eventspower-level map for both state and message events, including the spec's allowed whitespace, sign and leading zeroes. - PARTIAL (EVT-12): the v3–v5 aliases sender-domain rule runs before membership/power checks; receipt-rejected events cannot be selected as named inbound auth events or as own-auth fallback during state resolution. Trusted rejection metadata is carried outside the event JSON and retained on materialization. Rejected candidates remain available for auth-chain traversal and reauthorization. Legacy fractional power-level parsing remains unresolved; the characterization test is not a closure claim.
- FIXED (OUT-6): server discovery retains at most 1024 positive/negative entries by default (constructor capacity zero disables caching), sweeps expired entries before hits and insertion, rejects invalid server names before network/cache admission, and bounds concurrent insertion. Oldest results are evicted at capacity.
- FIXED (OUT-8): configured IPv4/IPv6 literals and CIDRs supplement the mandatory SSRF blocks throughout discovery, cache, well-known pre-connect and filtered destination resolution. The production discovery network owns the startup policy, including in workers.
- FIXED (CRY-3, CRY-4): master roots shorter than 32 bytes or entirely zero are refused; secret buffers and every derived-key/cached-key copy use isolated sodium allocations with independently verified locking. Exhausted locked memory returns no key. Historical BLAKE2b inputs and labels are unchanged so stored signing-key ciphertext and token digests stay compatible; the audit's proposed in-place KDF switch is rejected in ADR-0128 rather than silently invalidating persisted data.
- FIXED (CRY-5, CRY-6): validation-session lookup and consumption use the variable-length constant-time comparison boundary. New access/refresh token issuance requires v4 and returns 503 on unavailable keys without adding credentials; failure to copy a cached key into locked memory is covered explicitly.
- IN PROGRESS (FED-12): reproduced wrong-origin and completed-parent EDU persistence through real signature verification, authenticated encrypted IPC and reopened SQLite: normal build succeeded; 97 / 109 assertions passed and 12 behavioral checks failed, with healthy delivery and signature controls passing. Main-side remediation binds owned verified origin to the exact channel's live pending parent and checks admission after the runtime-lock wait. Ordinary requests cannot authorize a relay; pending locks never cover a sink. Worker parent propagation and legitimate-fixture migration are implemented. Normal post-format GREEN passes 109 assertions / 1 regression case; related worker EDUs pass 173 / 5 and worker flows 2059 / 38. The actual socket-HTTP / separate-worker mixed PDU/EDU control passes 262 / 1; proxy checks pass 12 / 2. Supplemental lifecycle checks pass 49 / 2 (first-GREEN coverage, not a new RED), IPC units 396 / 50, worker units 340 / 42 and sync handlers 382 / 16. All 79 focused source bindings are stable; the 11-file owned-added-lines scan has no pattern indicators and all 11 owned range-format checks pass. Independent source review and fresh combined acceptance remain pending. This is not whole-finding/release closure or signed-body binding. See proposed ADR-0142.
- PARTIAL (OPS-4): configuration parsing and typed validation now reject
negative access and refresh token lifetimes with field-specific findings rather
than silently disabling expiry. Zero remains the explicit no-expiry setting;
defaults and full signed numeric parsing are preserved. The actual parser
regression reproduced 12 rejection failures / 14 assertions with both healthy
control assertions passing before repair; it now passes 20 assertions / 1 case
including explicit default/zero controls. Signed-boundary checks pass 4 / 2
and normal configuration units 845 / 176 against 41 unchanged focused sources.
Stored sessions, expiry text and token digests are unchanged. Independent
source review passed without security or logic findings; parent inspection
bound the reviewed files to executed GREEN and verified preservation of prior
shared edits. The fresh combined test checkpoint passed 56 OK targets with
all return codes zero, no failures/timeouts and 150 unchanged snapshotted files
including all 41 focused sources. The source freeze ended after parent
inspection of completed archives and current-source binding. After renewed
explicit authorization, the added-line security scan checked both changed C++
files and reported no findings; all 41 focused code/test sources matched
the reviewed and tested state at that checkpoint. The bounded negative-admission
slice is accepted.
Refresh-enabled
/loginand/refreshnow omitexpires_in_mswhen the access lifetime is zero, as Matrix v1.19 requires; finite lifetimes retain the positive expiry field. A new real SQLite/public-dispatcher conformance scenario reproduced both omission failures (221 passing / 2 failing assertions) before repair and now passes all 223 assertions / 1 case, including finite, stored-expiry, token-usability and legacy-login controls. Related auth conformance passed 243 / 4; all 48 focused sources were unchanged during those runs. The added-line/complete-new-file security scan reported no findings in both C++ files, and inverse-hunk checksum verification confirms all preceding shared production edits are preserved. Bounded independent source review passed with no security, logic or suggested changes. Parent inspection bound the reviewed production and regression to executed GREEN and verified that the later OUT-3 registration entry preserves the entire earlier Meson file. Fresh current-source conformance again passed 223 / 1 and related auth 243 / 4 after the later OUT-3 and configuration changes. The full checkpoint below accepts this bounded response slice; its earlier 48-source snapshot remains historical, not a current whole-tree claim. Positive lifetime admission now has project ceilings of 2,592,000,000 ms (30 days) for access and 31,536,000,000 ms (365 days) for refresh. A registered real parser/direct-typed matrix reproduced 20 missing-rejection failures with 126 passing assertions before repair, covering both fields, zero, one, defaults, inclusive maxima, maximum-plus-one and the signed maximum. Exact typed values survive parsing and validation even when policy rejects them. No stored sessions are rewritten. ADR-0141 records these project limits, not Matrix requirements. The upper-bound regression now passes 146 assertions / 1 case; negative admission passes 20 / 1, signed-boundary parsing 4 / 2 and normal configuration units 991 / 177. The corrected exact-tag run completed with all statuses zero and 61 sources stable during execution. A two-file added-line/complete-new-test scan reported no indicators; inverse-boundary checksums verify preceding shared production and registration edits are preserved. Independent source review passed without security, logic or suggested changes; parent checks bind all three reviewed source/registration hashes to executed GREEN. The fresh full checkpoint passed 56 OK targets, all return codes zero and no failures, timeouts or other result categories, with 170 files unchanged throughout execution including all 61 focused sources. Parent independently reconciled fresh archived JSON/text logs and current reviewed source bindings, then released the freeze. The bounded upper-admission slice is accepted; subsequent changes are acceptance-status docs only. Checked timestamp arithmetic, direct typed runtime snapshot/reload admission, registration response expiry and broader persisted hydration remain unresolved or unverified; no whole-finding/release closure. - PARTIAL (OUT-3): added a real SQLite/public-dispatcher/loopback-TLS
recipient-isolation regression. Its healthy notification control, ten held
attacker gateways and cleanup passed before repair, while the other recipient's
notification failed (262 passing / 1 failing assertion). Delivery now uses
independently admitted recipient batches, a global bound and a per-recipient
cap of
G > 1 ? min(8, G - 1) : 1, retaining valid serial behavior atG=1. Move-only RAII permits release both atomic counters on completion and unwind; future parking capacity is reserved before launch. No configuration key or persistent pusher format changed. Normal integration now passes 263 assertions / 1 isolation case and 1,092 / 18 related push cases, with all 60 focused sources stable during execution. A four-file added-line/complete-new-test scan reported no indicators, and inverse-boundary checksums prove preceding shared production edits are preserved. ADR-0140 records the policy. Independent source review passed with no security or logic findings; parent checks bind all four reviewed production/test hashes to executed GREEN and the scan. The same fresh 56-OK, 170-stable-file full checkpoint above includes all four reviewed files and accepts the bounded recipient-isolation slice. Nonblocking suggestions cover fault-injected allocation/launch/ work failures, zero/single/eight-slot boundaries, concurrent admission and churn. Gateway circuits, registration quotas, mixed-recipient execution coverage and whole-finding/release acceptance remain unestablished. - PARTIAL (DB-8): preserved the existing remote verified-TLS guard and
repaired its parent-default dependency. Remote connection options must now
explicitly select the verified mode; an omitted or empty mode cannot pass
only because the parent supplies
PGSSLMODE. Local socket/loopback exemptions, host/host-address checks, service refusal, private readers, roles and stored formats are unchanged. A deterministic URI/keyword regression with healthy controls failed 4 / 28 assertions before repair and passes 28 / 1 afterward; all DB-8 checks pass 48 / 2, DB-9 checks 6 / 1 and database units 18363 / 174. The user manual and persistence guide now document strict remote admission, trust provisioning and the minimal worker environment; ADR-0138 records why forwarding parent defaults or adding insecure opt-outs was rejected. Earlier DB-8/DB-9 RED/GREEN logs were recovered as historical evidence. Independent review passed with no security or logic findings; parent inspection bound the reviewed files to executed GREEN and checked preservation of prior shared edits against accepted source checksums. The fresh combined checkpoint passed 56 OK targets with all return codes zero, no failures/timeouts and 135 unchanged snapshotted files. The source freeze ended after acceptance. Normal DB-6 integration compatibility passes 2733 / 15; both isolated PostgreSQL modes pass 178 / 2 with verified shutdown/owned-fixture removal and tested-source binding. TLS handshake/certificate/hostname and worker trust-file access are not established by these tests. No whole-finding/release closure. - PARTIAL (DB-9 verification): retained the existing fixed PostgreSQL diagnostic identity and generic parser/connection-status refusal reasons; no new production defect was identified in the inspected opener paths. Added registered actual DEBUG-console integration coverage for malformed quoted options and four policy-admitted quoted/spaced/escaped/URI forms reaching real libpq failure at an owned private nonexistent socket path. Runtime-only whole/partial credential markers are absent from emitted records and returned reason/identity, with event-presence controls and environment/stdout/owned-path cleanup checks. The new tag passes 85 assertions / 2 cases and normal database integration 2628 / 58 against 30 unchanged current sources; existing DB-9 units pass 6 / 1. This is first-GREEN coverage, not a new RED/production-fix claim. Persistence/dependency docs and ADR-0139 explain why all connection options, including host/database values, remain suppressed rather than reconstructed. Bounded independent source review passed without security or logic findings; parent inspection bound the reviewed files to the same executed 30-source manifests. Current database units pass 18363 / 174, DB-8 units 48 / 2 and DB-6 integration 2733 / 15. Fresh combined acceptance passed 56 OK targets with all return codes zero, no failures/timeouts and 139 unchanged snapshotted files including all 30 focused sources. Source freeze ended after parent inspection of completed archives and current-source binding. Native Windows socket execution, live authentication/TLS/ready paths, allocation and startup fault injection, and general SQL-result error confidentiality remain outside this coverage. No whole-finding/release closure.
- PARTIAL (ISO-7): bounded master-key, registration-token, main/worker database URI
and bootstrap-password reads validate ownership, mode and regular-file type after
O_NOFOLLOW|O_CLOEXECopen and read the validated descriptor directly into locked storage. Remaining TLS/private-key and cache identity/load race review is not a closure claim. - FIXED (OPS-6): the bootstrap password is a bounded, service-owned mode-0400 non-symlink file read into locked storage and wiped after bootstrap. The manual now uses a private temporary directory and correctly states that bootstrap continues serving.
- TESTS (crypto batch): parent rebuilt focused crypto/auth regressions passed 777 assertions / 102 cases, including historical derivation compatibility and forced locked-memory exhaustion for both caches. The new exhaustion regressions were observed failing before their fixes. Combined-suite coverage is recorded in the successful post-repair checkpoint below; the remaining audit pass is not complete.
- FIXED (ISO-5): thumbnail exec receives only a fixed PATH, with stderr redirected
to a pre-opened, RAII-owned
/dev/nullsink on both exec paths. Parent-side PNG signature and initial IHDR dimension checks refuse malformed or mismatched output with 502 and no media payload. No decoder or image-data validation moves into main. - FIXED (FED-9): only an explicitly present, empty
pdusarray enables the EDU-only/sendbypass. A malformed first PDU followed by real PDUs retains an empty shard key and enters the worker instead of main; later PDUs are not used to select a main-process failed-sync fallback. Parent source/proxy review and independent normal-binary runs passed: 27 assertions / 1[fed9]case and 167 assertions / 26[routing]cases. ADR-0131. No dedicated live IPC regression ran. - PARTIAL (FED-10): relayed sender-key resolution now shares the source-address rate budget, runtime-wide in-flight cap and server/key negative cache across parallel pre-resolution and authorization fallback paths. A network-free key-only provider preserves cached verification, retained local keys and known-peer rotation; fresh keys no longer exempt production discovery. Raw/verified router paths retain source identity. Cache-only origin lookups cannot release another request's slot; asynchronous resolver exceptions release scheduling permits and keep callable lifetimes valid. Normal-target verification passed 474 assertions / 5 unit cases, 41 / 3 real database/router integration cases and 963 / 54 related inbound cases. Budget, cache, exception, concurrency and production-wiring behavioral RED evidence is retained. ADR-0132. The reviewed overload-to-trust/replay regression is repaired: explicit outcomes preserve local denial across parallel, direct and restricted-join paths; 503 leaves the original transaction retryable without charging or resetting trust. Actual/negative-cache failures retain penalties in mixed transactions, and EDUs wait for retry. ADR-0133. Independent all-target build and fresh normal-binary runs passed 681 assertions / 9 unit cases, 122 / 4 real database/router integration cases and 1170 / 58 related inbound cases. Behavioral RED evidence is retained. Independent review confirmed the core overload repair; the completed unified WSL checkpoint passed 56 targets with zero failures/timeouts. Its separate main IPC bypass is now repaired: PDU, membership and invite re-verification share the admitted key-only helper, with all main relay misses using the common unknown source quota rather than a worker-supplied address. Local denial returns the existing PDU overload status or unaccepted 503 before mutation. A genuine encrypted-IPC RED failed 94 assertions before implementation. Normal integration runs then passed 1228 assertions / 3 new cases, 156 / 3 existing signature cases and 1350 / 7 FED-10 cases. Cache identity/event-time validity, unchanged-relay retry, foreign slot ownership and bounded pair negative caching are covered. ADR-0134. A mixed negative-cache/EDU unit regression also exposed genuine trust penalties being discarded by a later main-overload return. Its behavioral RED failed one assertion; conditional persistence now retains only actual failures, defers EDUs and leaves the unchanged transaction retryable. Final normal unit FED-10 passed 716 assertions / 10 cases and related inbound 1205 / 59. The first main full run had 55 OK / 1 failed fixture; its 50-PDU room-interest regression now reads the already-known key from cache instead of spending discovery quota. Production limits are unchanged. Fresh completed WSL CLI passed 56 targets, zero failures/timeouts, with one skipped unit case; logs are archived and the six focused source/test hashes remain unchanged. Independent source-only review approved this main slice with no material findings. End-to-end worker HTTP overload propagation remains a coverage gap: the IPC fixture substitutes PDU result serialization; production serialization, worker decoding and HTTP propagation were source-traced only. Backfill/snapshot/auth-chain key lookup now shares the admitted key-only helper and unknown-source bucket. Local denial stops subsequent recovery calls and preserves the existing PDU overload result instead of accepted missing state; already-verified outliers are not rolled back. Genuine TLS/ SQLite RED failed 13 of 172 assertions before the fix; normal post-format integration passed the new 172 / 1 case, all backfill 2173 / 17 and total FED-10 1522 / 8. ADR-0135. Fresh post-backfill full CLI completed exit 0: 56 OK targets, zero failures/timeouts, one skipped unit case; archived logs and eight unchanged source/test hashes confirm the tested snapshot. Material review of the original production slice is accepted. The two later test delegates failed provider limits, leaving unfinished fixtures; their edits were preserved, inspected and repaired directly without loosening production checks. Independent post-format normal integration passed actual worker/socket-HTTP overload and unchanged-transaction retry (262 / 1), sticky recovery denial (200 / 1), partial-progress retry/deduplication (314 / 1), all backfill (2687 / 19) and FED-10 integration (2298 / 11). The fresh recovered full checkpoint passed 56 OK targets, all return codes 0 and zero failures/ timeouts, with tracked source unchanged. This supplies separate-process production serializer/worker decoder/HTTP execution evidence, not merely the earlier substituted IPC fixture. Direct state/auth-chain denial and backfill cache rejection/negative-hit cases remain unexecuted coverage suggestions; aggregate cross-shard limits and whole-finding acceptance are not established. No BSD claim.
- PARTIAL (DB-6): local composition now uses the same NUL state-key
admission rule as inbound parsing, before allocating a stream ordering.
createRoompreflights the complete initial-state list before persisting room/bootstrap state, including a bad entry after a valid one. Direct local state sends answer400 M_INVALID_PARAM, not a late database refusal or a successful in-memory mutation. Genuine local-admission RED failed 42 of 259 assertions; the repaired normal integration group passed 259 / 3 cases. Public client and local-router entry points also reject literal or once- decoded NUL in the path before a decoder can throw, with controlled400 M_INVALID_PARAM; public responses retain configured CORS. The query suffix is not part of this path guard, and%2500is not double-decoded. Genuine HTTP RED failed 24 of 64 assertions; original repaired admission passed 82 / 1 case and compatibility 4 / 1 (all DB-6 integration 345 / 5). Additional explicit local errcode and configured Origin/Vary CORS checks now pass 100 / 1. Source tracing after an incomplete independent review exposed the same exception in recognizedvia/server_namejoin queries: genuine RED failed 16 of 47 assertions; checked optional parsing now returns400 M_INVALID_PARAM, with normal GREEN 63 / 1 and ordinary,%2500and unrelated%00query positives. Local admission remains GREEN 259 / 3. Real isolated PostgreSQL 16 inbound/compatibility tests passed 180 / 2, both initially and after the join-query repair: a signed control persists, NUL state keys cause no statements/event/state/graph/watermark writes, and Unicode state keys, escaped-NUL content and binary BYTEA remain intact. The private Unix-socket-only test database is stopped and removed after each run; 14 tracked admission-source hashes remain stable. Initial post-format unit DB-6 passed 9 / 3, query 127 / 12 and routing 167 / 26. Startup/compilation failures were repaired as fixture/build failures, not counted as behavioral RED. The first independent source review withheld approval after a tool approval timeout, with no established implementation defect; a bounded re-review is pending. Real no-proxy federation socket- HTTP RED failed 16 of 216 assertions with authenticated controls reaching the handler. The federation entry point now rejects NUL paths before routing/replay with a structured400 M_INVALID_PARAM: normal GREEN passed 246 / 1, including nine leading/interior/trailing malformed room, member and event ID cases, same-socket keep-alive controls,%2500positives and unchanged memory/reopened SQLite rows and watermarks. The accompanying HTTP/local/join groups still pass 100 / 1, 259 / 3 and 63 / 1; 19 tracked source hashes remained stable during that run. A second real healthy-proxy/separate-worker socket regression then exposed main's earlier shard-key decoder: genuine RED failed 7 of 293 assertions. A matching guard inFederationProxy::handle, before bypass/auth/routing, now passes 311 / 1; combined transport passes 557 / 2. Controls are served by the healthy worker without failed-room-sync fallback. The default DB-6 integration filter passes 985 / 10 (including two opt-in PostgreSQL fallback assertions, not live database proof); a separate post-proxy live PostgreSQL run passes 180 / 2, with clean shutdown/removal and 19 stable source hashes. Fresh unit groups remain 9 / 3, 127 / 12 and 167 / 26. Independent source review approved the federation guard/socket slice; parent verified its four reviewed source/registration hashes still match the executed GREEN. A completed local/query/PostgreSQL review withheld approval for path parsers that still decode unrelated query values, unguarded direct-local join hints and a URI-only CI fixture mismatch. The latter is reproduced against isolated real PostgreSQL: both opted-in scenarios fail their missing-file prerequisite (2 assertions / 2 cases), followed by verified shutdown/removal. The fresh combined attempt exited before tests because the scratch CLI wrapper expanded an unset temporary- directory variable; its archived stale logs are not acceptance evidence. The wrapper was corrected and its actual CLI dry run succeeds, not a full test pass. The review-confirmed gaps now have genuine regressions: direct-local join hints failed 16 / 108 checks, then a checked optional parser and controlled refusal passed both dispatchers. Stronger SQLite snapshots cover event/signature/graph/membership/invite rows and both watermarks; the current join group passes 188 / 1. Authenticated ordinary,%00,%2500and literal-NUL unrelated-query cases exposed path mixing across send, redact, state GET/PUT, report and typing (48 failed / 174). Those parsers now take the path only; GREEN passes 230 / 1, including original transaction idempotency, intended state keys, durable report targets and typing subjects. A missing-header compilation attempt is recorded as a build failure, not behavioral RED. PostgreSQL scenarios now provision an exclusive, owner-only RAII connection file from the same explicitly opted-in URI used by reopened-store checks; caller files are not selected or modified. Both URI-only and URI-plus-file isolated real runs pass 178 / 2, with owned-file cleanup and verified cluster shutdown/removal. The two fewer assertions are the removed file-presence prerequisites, not skipped admission checks. Normal DB-6 integration passes 1340 / 11 (two opt-in fallback assertions, not live PostgreSQL), including unchanged socket 557 / 2, local 259 / 3 and HTTP 100 / 1. Unit groups remain 9 / 3, 127 / 12 and 167 / 26; 21 tracked admission hashes are stable. Independent bounded repair re-review passed with no security or logic findings; all seven reviewed files matched executed GREEN. The fresh review-gap full CLI completed exit 0: 56 OK, zero failures/timeouts, fresh archived text/JSON reports, all return codes zero and 127 snapshotted files unchanged. This accepts that checkpoint, not later edits. Further decoder tracing found raw query text still reaching to-device, knock and user-metadata paths. A separate real SQLite regression reproduced 34 failed / 169 assertions, with successful no-query controls, original transaction replay, actual recipient queue delivery, intended account-data/tag keys and durable knock membership. Those three extraction boundaries now strip queries. The first GREEN build failed before tests because the to-device helper referenced a later declaration; local path-only slicing fixes that compilation error without changing decoding semantics. The corrected GREEN rerun passes 255 / 1; original query paths 230 / 1, joins 188 / 1, HTTP 100 / 1, compatibility 4 / 1 and real sockets 557 / 2 remain green. Current normal DB-6 integration passes 1595 / 12 (two gated PostgreSQL fallback assertions, not live database proof); unit groups pass 9 / 3, 127 / 12 and 167 / 26. Builds/runs exit 0 with stable admission source hashes. Current isolated PostgreSQL URI-only and URI-plus-file reruns each pass 178 / 2 with owned connection-file cleanup, cluster shutdown/removal and stable source snapshots. The scoped static scan has only the unchanged, previously disposed OPS-2 marker false positive. The next bounded review found no blocking defect in these three repairs or their test, but rejected decoder completeness: public profile GET, profile PUT, shared room action/receipt suffixes and presence still slice raw targets. Parent source inspection corroborates these gaps, including an ordinary receipt query entering the recorded event ID. A separate registered real SQLite/password-authenticated regression then reproduced 206 failed / 691 assertions, after its mandatory complete no-query control passed. All eight nonempty query/marker cases reached failures. It checks public profile fields, intended profile/presence persistence, whole state, exact receipt event IDs, durable fully-read markers and leave-last membership. Only after that RED, four extraction boundaries were changed to use the existing path-only request view. GREEN passes 733 / 1; current normal DB-6 integration passes 2328 / 13 (two gated PostgreSQL fallback assertions). Earlier query/join/HTTP/socket groups and unit 9 / 3, 127 / 12 and 167 / 26 remain green. Current isolated PostgreSQL URI-only and URI-plus-file reruns each pass 178 / 2, with verified owned-file cleanup and cluster shutdown/removal. At that checkpoint, all 22 admission sources matched executed manifests. The subsequent review found no remaining query-to-path-decoder feed in its bounded inventory, but rejected an introduced/membersregression: recognized queries were still read from the now-clean room suffix. Real-dispatch conformance reproduced 50 failed / 164 assertions (the history group failed 52 / 1789). Separately extracting the original query then passed 164 / 1. A separate test exposed the pre-existing intersection of combined member filters: RED 16 / 73, then GREEN 73 / 1 after implementing the pinned specification's union semantics. Current history conformance passes 1816 / 17; DB-6 integration remains 2328 / 13, earlier focused/unit groups remain green, and both current isolated PostgreSQL modes pass 178 / 2 with verified cleanup and teardown. All 23 current admission sources match conformance/integration/unit/both PostgreSQL manifests; scoped static findings are unchanged and diff checks pass. Fresh member-repair source review passed with no security or logic findings; both reviewed files match executed GREEN. That postmembers combined checkpoint completed with 56 OK targets, all return codes zero, no failures/timeouts and 129 unchanged snapshotted files. It accepts that earlier snapshot, not the subsequent repairs. The separate device query regression required public HTTP login to populate the client device cache; setup/compiler failures are not behavioral RED. Its healthy control then passed, while all four unused-query cases reproduced 56 failed / 216 assertions across GET, PUT and password-UIA DELETE. Path-only device extraction now passes 224 / 1, including SQLite reopen, target-session revocation and preservation of the owner's device/session. Independently, backup-version DELETE reproduced 24 failed / 181 assertions with a healthy no-query control: HTTP success could leave the intended version undeleted. Extracting its version from the path now passes 181 / 1, including a surviving second version, repeat deletion and reopened durable rows. Current normal DB-6 integration passes 2733 / 15 (two gated PostgreSQL fallback assertions); history conformance remains 1816 / 17 and unit groups remain green. Isolated PostgreSQL admission/compatibility reruns each pass 178 / 2 with verified owned-file cleanup and cluster shutdown/removal; all five execution manifests match the same 23 current sources. These are not live PostgreSQL device/backup regressions, wire/TLS checks, arbitrary encoded-device-ID support or backup encryption/session-cascade coverage. Independent nondecoder source review passed with no security or logic findings. The fresh combined checkpoint passed 56 OK targets, all return codes zero and no failures/timeouts, with 130 unchanged snapshotted files; parent inspection bound the focused fixes to that run. The source freeze ended after acceptance. DB-6 remains partial and ADR-0137 proposed. - IMPLEMENTED (OPS-2): shared audit targets now discard the entire query
string and retain only a control-safe, UTF-8-safe 255-byte path/identifier.
The same policy protects durable and in-memory audit paths, actual
audit.appenddebug output, event construction, INSERT statements and summaries. Known, unknown, encoded and duplicate parameter names cannot expose query values. Constructor RED failed 20 assertions; separate SQLite/HTTP/sink RED failed 8. Final focused unit OPS-2 passed 101 assertions / 2 cases, observability 10447 / 40 and audit 14263 / 45. Integration OPS-2 passed 44 / 2, including actual rate limiting, unavailable-runtime rejection, SQLite reopen and exact logger policy restoration. Independent review rejected the original capture fixture; a genuine restoration/unwinding RED failed 4 of 6 assertions. Exact threshold/optional-override exchanges, synchronous-writer checks and buffer redirection after configuration repair it; the scope regression passed 20 / 1. The first combined build failed the minimal logger fixture's missing encoder linkage; tests/meson.build now supplies the existing pure bounded-text source. A subsequent 56-target run preceded review correction and is not final acceptance. ADR-0136 records the intentional loss of even safe query values; historical rows, backups and logs are not rewritten. Independent re-review approved the repaired slice with no material findings. Its first final full run had 55 OK / 1 unrelated to-device TTL fixture failure. Its exact cause was not established; the previous test depended on a 1.2-second sleep to establish an expired-row precondition. The memory-store regression now seeds a definitely old positive timestamp instead of depending on wall-clock progress, proving already-expired non-delivery and purge rather than enqueue timestamp correctness or exact TTL boundaries. Production TTL logic is unchanged. Independent source-only fixture review passed with no material findings; five focused runs and the related CSAZ-10 cases passed. The resumed full verification exited 0: all 56 targets reported OK with return code 0, no failures or timeouts, and all 95 snapshotted files were unchanged. Exact completed reports are archived; the previous 55 OK / 1 failed run remains recorded rather than overwritten. Audit-inventory reconciliation is still pending: its separate execution approval timed out before the reconciliation script ran. - HARDENING REGRESSIONS (ISO-6 / DB-10): main's seccomp admits only descriptor-bound
fchmod(0600)and privateumask(0077). This permits SQLite admission and worker startup under the inherited filter without broad metadata grants; both workers still deny all fchmod/umask once their stricter profiles are installed. ADR-0129/0130. These fixes do not close the remaining ISO-6 or DB-10 acceptance review. - TESTS (decoder/platform batch): ISO-5 passed independently in the normal integration binary (554 assertions / 4 cases), including a separate closed-stderr run (13 assertions / 1 case). New permission/mask kernel regressions were observed RED then GREEN; the combined focused platform runs passed 378 assertions / 20 unit cases and 606 assertions / 13 integration cases, including real hardened startup. The earlier full run failed; the post-repair unified WSL rerun passed all 56 Meson targets with zero failures or timeouts. One Landlock ABI-6 unit case was skipped on the available kernel; this is a checkpoint, not closure of remaining findings.
- TESTS: new legacy event-auth, rejected-auth fallback, discovery bounds and denied-range scenarios; updated canonical JSON and inbound PDU auth scenarios. Parent verification of these exact tags passed: unit 1717 assertions / 15 cases; conformance 1331 assertions / 4 cases. These are also covered by the successful post-repair checkpoint above; remaining remediation still needs final acceptance.
0.12.25¶
CSAZ-6, CSAZ-9, CSAZ-11 and CSAZ-12 of the 29 September 2026 security audit are fixed.
- FIXED (CSAZ-11): redactions were relayed but never applied. Redacted content stayed readable on
every read path and over federation, and
PUT /rooms/{roomId}/redact/{eventId}/{txnId}was not routed. A redaction is now judged by the room version's "Handling redactions" rule against the power levels in its ownauth_events(or the sender's domain matching the original sender's), applied by overwriting the target's stored JSON with the redacted form (the original content is deleted), served withunsigned.redacted_because, and withheld from clients, push and sync wake-ups until it applies.redactsis read and written where the room version keeps it.PUT /redactis routed: a user may redact their own events, another user's at theredactlevel, and a server administrator any local user's event. On the first start, redactions accepted by earlier builds are applied. ADR-0125. - FIXED (CSAZ-6): a ban or kick sent through
PUT /state/m.room.memberdid not take effect. It changed room state, but the user stayed in/joined_membersand/joined_roomsand could rejoin, because only the membership APIs updated the membership tables.persist_composed_eventnow projects every locally composedm.room.memberevent that becomes current state (memberships,LocalRoom.members, device-list share changes, invite metadata); the membership APIs, join andcreateRoomno longer do it themselves. A banned user still reads the history they could see before the ban, and nothing after it, as the spec's history visibility rules require. ADR-0126. - FIXED (CSAZ-9): locally created events were not held to the spec's size limits. An event whose signed
canonical JSON is over 65536 bytes, or whose
typeorstate_keyis over 255 bytes, is refused with400 M_TOO_LARGEon/send,/state, the membership endpoints andcreateRoom, and nothing is stored (createRoomchecks client content before storing the room).OperationResult::errcodeandLocalHttpResponse::errcodecarry a specific errcode to the client; a 400 used to becomeM_UNKNOWN. - FIXED (CSAZ-12): reports, upgrades, directory visibility and aliases were not gated.
POST /rooms/{roomId}/report/{eventId}answers404 M_NOT_FOUNDunless the reporter is joined and the event is in the room and visible to them; the path is percent-decoded; a repeat report of the same event by the same reporter writes no second audit row.POST /rooms/{roomId}/upgradeanswers403 M_FORBIDDENbefore creating anything unless the user may sendm.room.tombstone, and fails if the tombstone is refused instead of reporting success.PUT /directory/list/room/{roomId}needs the power to sendm.room.canonical_alias, or a server administrator.PUT /directory/room/{roomAlias}validates the alias grammar and this server's domain (400 M_INVALID_PARAM) and needs canonical-alias power or an administrator.- NEW:
DELETE /directory/room/{roomAlias}, for the alias's creator, a joined user with canonical-alias power, or an administrator;404 M_NOT_FOUNDwhen unmapped. ADR-0127. - MIGRATION 021:
room_aliases.creator_user_id(empty for existing aliases, which then need canonical-alias power or an administrator to delete).database::delete_room_alias. - NEW:
events::required_state_event_power(the auth rule's required level for a state event, now shared with the client API gates),homeserver::may_send_state_event,auth::room_alias_is_valid,events::max_event_type_length_bytes. - TESTS:
[csaz-6]tests/conformance/test_membership_projection_conformance.cpp,[csaz-9]tests/conformance/test_event_size_limits_conformance.cpp,[csaz-12]tests/conformance/test_room_admin_gates_conformance.cpp, the alias validator intests/unit/test_auth.cppand the alias creator and deletion intests/unit/test_database_persistence.cpp; CSAZ-11 intests/conformance/test_redaction_application_conformance.cpp,tests/conformance/test_redaction_federation_conformance.cppandtests/unit/test_redaction_validity.cpp. The twoDELETE /directory/room"implementation gap" conformance scenarios now expect the spec's404 M_NOT_FOUND; the trust-and-safety unit scenario and the AUTH-1 safety-report listing scenario (tests/integration/test_persistent_homeserver_flow.cpp) report real events; the migration-count assertions count 21 steps. - FIXED:
ConnectionParker::parked()could still count a connection its peer had already seen closed. The poll loop closed expired, hung-up and invalid connections first and decrementedparked_countafter the whole pass, so a peer woken by the close could read the old count (NetBSD CI:test_net_connection_parker.cpp:393). The count is now decremented before those connections are closed; closing stays outside the lock. - TESTS: refused membership actions (
/inviteby a non-member,/banand/unbanwithout power,/forgetwhile joined,/knockon a non-knock room) answer the spec's 403 or 400 and change nothing; malformed redactions name no target and never apply (tests/unit/test_redaction_validity.cpp).may_send_state_eventrefuses when the room's create event is missing or has no readable content, andPUT /redactwith a body that is not a JSON object answers400 M_BAD_JSONand redacts nothing. - BUILD:
merovingian-authlinksmerovingian-canonicaljson(auth::room_alias_is_validandoidc_discovery.cppuse it);src/meson.buildconfigures canonicaljson first. The fuzz config-parser target, which links auth without canonicaljson, failed to link. - TESTS: refusal paths of the CSAZ-12 gates (malformed alias, unknown room, 409 conflict, administrator
mapping, invalid visibility, unsupported upgrade version, report in an unknown or different room), every
createRoomsize pre-check field, andtests/unit/test_room_power.cpp([power]). - Docs: the audit report's status (14 lows fixed),
docs/event-engine.md,docs/threat-model.md,docs/trust-safety.md,docs/database-persistence.md,docs/security-coding-rules.md,docs/matrix-v1.19-client-server-api.md,docs/todos/capability-gaps.md,migrations/AGENTS.md,src/homeserver/AGENTS.md.
0.12.24¶
- FIXED: a waiting
/syncwas counted against the rate limit again every time it was run again. A long-poll is run once more after each wake-up and when its timeout expires (on the sync pool, in the no-pool fallback and indispatch_local_http_request), and each run went throughallow(). A client allowed one more/syncwas refused with429at the moment its waiting sync was answered. The re-runs now passrate_limit_admitted(rerun_waiting_sync), as the media fetch pool's re-run does (0.12.21), so each/syncis counted once, when it first arrives. - TESTS:
[http-4][sync][rate-limit]intests/integration/test_http_server_listener_flow.cpp: under a sync limit of one per minute, every re-run path is answered200: on the sync pool, on a request worker with no sync pool, and indispatch_local_http_request, each both at its timeout and when woken by a new room; the client's next/syncin the window is still refused. Each path fails with429when the re-runs are counted again. - NEW:
sync::SyncNotifier::waiting(), the number of callers blocked inwait_for_change, so a test can tell when a long-poll has started waiting ([sync][notifier]scenario). - TESTS: the listener tests' response reader (
receive_responseintests/integration/test_http_server_listener_flow.cpp) gives up after 30 s instead of blocking forever. A connection the server neither answered nor closed made OpenBSD CI kill the whole integration binary at its 900 s limit, mid-scenario; a stall now fails only that scenario. - CI:
scripts/build-bsd.shrunsmeson testwith--timeout-multiplier 2unlessMESON_TEST_TIMEOUT_MULTIPLIERsays otherwise. The BSD CI VMs' speed varies by 20-40% between runs, and the unit, conformance and integration binaries each hit their meson limits on OpenBSD runs where nothing hung. Test:tests/tooling/test_dependency_wraps.py. - Docs:
docs/http-transport.md(sync re-runs; the default per-account and per-device wait caps are 4 and 2, not 8 and 4 as it said).
0.12.23¶
Every AUTH finding of the 29 September 2026 security audit is now fixed.
- FIXED (AUTH-2): a stranger could hold any account in login lockout. Five failed logins
refused every login for that account for 15 minutes, correct passwords included, and blocked
password change, deactivation and device deletion too. Failures are now counted per (account,
client source) and per account:
security.login_throttle.max_failures_per_source(5) refuses only that source,max_failures_per_account(50) refuses every source, withinsecurity.login_throttle.window(15m). A correct login clears only its source's counter. User-interactive-auth password checks have their own (account, device) counter, which login failures never touch, so the owner's sessions keep working.docs/auth-identity.mdno longer claims a real login clears the lockout. ADR-0122 (supersedes ADR-0066, refines ADR-0032). - FIXED (AUTH-10): the failed-login map was unbounded and scanned on every failure. The new
auth::FailureWindowTablestores a fixed-size BLAKE2b digest per key, holds at most 100,000 entries, and expires entries from a list ordered by window start. - FIXED (AUTH-5): 3PID ownership was never validated.
requestTokenwithout an identity server created a session already marked validated and sent nothing, so a user could bind someone else's email address. Ownership is now proven only by a trusted identity server:requestTokenwithout one answers400 M_THREEPID_MEDIUM_NOT_SUPPORTED; delegated sessions start unvalidated and keep the identity server's URL and access token in memory only;/account/3pid/add,/bindandPOST /account/3pidconfirm with the identity server (getValidated3pid) for the same medium and address, otherwise400 M_SESSION_NOT_VALIDATED. A consumed session is erased so it cannot be replayed. A bind naming an untrusted identity server is refused instead of being recorded as bound. ADR-0123. - FIXED (AUTH-7): other services' exclusive namespaces were ignored. An application service
can no longer register (
400 M_EXCLUSIVE), log in as or assert (403 M_EXCLUSIVE) a user in another service's exclusive namespace. An out-of-namespace/registernow answers 400 rather than 403, as the spec's response table gives. - FIXED (AUTH-12): an application service could act as a deactivated or non-existent user.
Asserting a user that is not registered or is deactivated answers
403 M_FORBIDDEN; bridges must register a puppet before acting as it./registerand/loginare exempt. ADR-0124. - FIXED (AUTH-8): identity assertion applied to account management. A masqueraded request
to any
/account/*endpoint exceptGET /account/whoamianswers403 M_FORBIDDEN. - FIXED (AUTH-9, remainder): log field values were unbounded. Every structured log field
value is capped at 2048 emitted bytes on a character boundary, with a
...[truncated N bytes]marker; redaction runs first. - FIXED (AUTH-4, remainder): Argon2id in
/register, password change and UIA ran under the runtime mutex with no admission limit. Each now takes a slot in the shared Argon2id budget (429 M_LIMIT_EXCEEDEDwithretry_after_mswhen none is free, never counted as a failed attempt), hashes with the mutex released, and re-checks the username, session and stored hash once it is re-taken./register429s now carryretry_after_ms. - FIXED (AUTH-1, remainder):
login.rejectedandlogin.throttledaudit rows are rate-capped like other unauthenticated rejections. - CHANGED: the example config enables remote media fetching.
config/merovingian.conf.examplesetssecurity.media.remote_fetch_enabled=true, so a server set up from it can download attachments sent from other servers. The built-in default staysfalse: a config without the line refuses every remote download with 404 and sends nothing. - TESTS:
tests/unit/test_login_throttle_http.cpp,test_failure_window_table.cpp,test_config_login_throttle.cpp([login-throttle]);tests/integration/test_identity_service_flow.cppandtests/unit/test_identity_client.cpp([auth-5]) with a newMockIdentityServerintests/support/tls_mock_server.hpp;tests/conformance/test_appservice_assertion_conformance.cppandtests/unit/test_appservice_masquerade_token.cpp([auth-7],[auth-8],[auth-12]);tests/unit/test_security_audit_log_controls.cppandtest_audit_flood.cpp([auth-9]);tests/unit/test_security_audit_auth_4_admission.cppandtests/integration/test_security_audit_auth_4_admission_flow.cpp([auth-4]);tests/tooling/test_example_config.py. Each new test failed against the code before its fix. - Docs: ADR-0122, ADR-0123, ADR-0124; audit report remediation status;
docs/auth-identity.md, user manual, example config, observability, threat model, security coding rules, and thesrc/auth,src/appservice,src/identityandsrc/observabilityAGENTS.md files.
0.12.21¶
- FIXED: a federated user's attachment could not be downloaded in Element (ADR-0121). A
remote media download or thumbnail held a main request-pool thread for the whole fetch, so
ADR-0079 allowed one per client address and refused the rest with
429 M_LIMIT_EXCEEDED. Element Web downloads an image and its thumbnail at once, and every remote image on screen, and does not retry a media429: the image stayed broken ([429] too many concurrent remote media fetches). Remote media that must be fetched now runs on a dedicated media fetch pool. The main pool still does the quick checks (remote_fetch_enabled,allow_remote, policy, the remote media cache) and on a cache miss hands the request to the pool, as a waiting/syncis handed to the sync pool. A peer that never answers can occupy the media pool but no main-pool worker. Directory lookups (publicRooms?server=, remote aliases) keep the ADR-0079 budget. - NEW:
server.http.media_fetch_threads(16),server.http.media_fetch_max_in_flight(64) andserver.http.media_fetch_max_per_client(8). Workers of the media fetch pool, fetches running or queued, and of those for one client (the rate-limit key,trusted_proxieshonoured). Over either cap a request gets429 M_LIMIT_EXCEEDEDwithretry_after_ms1000 at once. Restart required. - FIXED: concurrent requests for the same remote file fetched it twice. The second admission
displaced the first copy from the remote media cache and deleted it while the first request
could still be reading it. Fetches of one remote file are now coalesced
(
RemoteMediaFetchCoalescer): a second request waits for the first, with the runtime mutex released and within its own deadline, and serves the stored copy. - FIXED: a request run a second time by another pool is no longer counted twice by the rate
limiter when it is a remote media request (
ClientServerDispatchOptions::rate_limit_admitted). The sync pool's re-runs of a long-poll are unchanged. - TESTS:
[media-fetch-pool]:tests/integration/test_remote_media_fetch_pool_flow.cpp(a stalled origin does not hold a request thread; three remote files from one client all served; a download and thumbnail of one file reach the origin once; per-client and stopped-pool backpressure; one rate-limit count per request) andtests/unit/test_remote_media_fetch.cpp(coalescer, fetch scope, admission caps); config scenarios intests/unit/test_config_operational_limits.cpp. - TESTS: a remote media download answered by the media pool keeps its connection alive and
hands it back to the dispatcher, which serves the next request on it
(
[media-fetch-pool][keep-alive], through a realHttpConnectionDispatcherand listener). - TESTS: the mock HTTPS server's certificate (
tests/support/tls_mock_server.hpp) is backdated an hour. ItsnotBeforewas the moment it was issued, so a wall clock stepping back a moment (WSL does) made verification fail as "not yet valid" in whichever scenario was unlucky. - TESTS: the logger thread-count scenario (
[logger][iso1]) waits for its calibration helper thread to leave/proc/self/taskbefore counting. A joined thread can stay listed for a moment afterjoin()returns, which charged it to the logger (Ubuntu CI:before=1 after_use=2). - FIXED: the Catch2 BDD gate timed out on OpenBSD CI.
scripts/check-catch2-bdd-tests.shran up to sixgrepprocesses per unit test file (~1,300 per run) and exceeded meson's 30 s test timeout on the OpenBSD VM. It now reads every file once in a singleawkprocess, with the same rules and messages (3.7 s to 1.0 s on WSL); the comment-marker rule no longer relies on the GNU-only\|alternation. Newtests/tooling/test_check_catch2_bdd.pypins the behaviour. - Docs: ADR-0121 (ADR-0079 superseded for remote media),
docs/http-transport.md"Remote media fetch pool", media repository, threat model, user manual, example config, security coding rules,src/homeserver/AGENTS.md.
0.12.20¶
- TESTS: the PostgreSQL-only media scenarios (
[postgresql][media]) read blob bytes after a reopen throughread_media_blob, since hydration no longer loads them, and the released-blob scenario checks thebytescolumn directly, becauseread_media_blobskips unreferenced blobs and would pass whether or not the bytes were cleared. - CHANGED: media bytes live in the database and are read on demand (ADR-0119). Every
stored media file used to be loaded into memory at startup and served from there, so
memory grew with stored media. Hydration now reads blob metadata only; downloads,
thumbnails, remote-cache hits and the federation media endpoint read the bytes per
request with the runtime mutex released, then re-check that the media may still be
served, so media removed or quarantined during the read is refused.
LocalMediaBlobhas no bytes member. Uploads write bytes straight from the request; a deduplicated upload updates only the reference count instead of rewriting the file.max_total_sizenow bounds storage, not memory; the defaults are unchanged. - FIXED: the federation media download read the media repository without the runtime
mutex. Its lookups rebuild the repository's indices, so it raced with uploads and
moderation. It now takes the mutex for the lookups (
download_local_media_for_federation). - FIXED: remote media was never written to the database. It is now stored like an
upload, and the remote media cache is durable (migration 020 adds
local_media_idandfetched_at_msto the previously unusedremote_mediatable). Admission is planned, committed in one transaction (commit_remote_media_admission) and then applied, so each stored remote copy stays reachable from one cache entry: a re-fetch after the TTL replaces the old copy, eviction atremote_media_cache_max_entriesdeletes it, the cache survives restarts, and quarantined or removed remote media is answered451/404from the cache instead of being fetched and stored again on every request.remote_media_cache_max_entries = 0now keeps at most one remote copy. - CHANGED: thumbnails are decoded with the runtime mutex released (low audit finding MED-4, decode part); the per-user thumbnail cap from that finding is not done.
- TESTS: new
tests/integration/test_media_on_demand_flow.cppandtests/integration/test_remote_media_cache_flow.cpp([media-on-demand]): bytes served from the database after they change underneath the server, a deduplicated upload writing no bytes, federation downloads alongside uploads (for ThreadSanitizer), the remote cache across a restart, TTL replacement, quarantine caching and eviction.[out-4]unit scenarios rewritten for the plan/apply admission; moderation-during-read guard tested. Tests that asserted bytes in memory now assert them through the database read path, or directly against the SQLite file where removal must clear the durable bytes. - CHANGED: media quota defaults sized for on-disk storage. Now that media bytes live in
the database, the defaults are
max_total_size=250GiB,max_size_per_user=10GiBandmax_records=1000000(chosen by the project owner; record metadata is still held in memory, roughly 400 MB at the cap).remote_media_cache_max_entriesdefaults to 50,000. - NEW:
security.media.remote_media_cache_max_size(default25GiB, ADR-0120). Cached remote media has its own byte budget: least recently used remote files are deleted to stay within it, and a single file larger than it is refused with507. Remote media no longer counts towardmax_total_sizeormax_records, so caching other servers' media cannot make a local upload fail. A local upload of bytes already stored only for remote media is charged to the local total. - FIXED (OPS-3): malformed media size limits are rejected.
max_total_size,max_size_per_userandremote_media_cache_max_sizemust be empty (no limit) or parse; a value such as10Gused to parse as 0, which meant no limit. The effective limits are logged at startup, the reload plan reports changes to them, andsrc/config/AGENTS.mdnames the accepted suffixes (B,KiB,MiB,GiB). - TESTS:
[med-6]pins the new defaults and checks that remote media is charged only to its own budget and evicted by bytes;[ops-3]checks that malformed sizes are rejected and empty ones accepted. - Version bumped to 0.12.20 for the media-on-demand branch (stacked on 0.12.19).
0.12.19¶
- FIXED: BSD builds failed on an unused Landlock constant.
k_scope_signal(ISO-2) was declared on every platform but used only where the Linux ruleset is built; under-Werrorthe FreeBSD, NetBSD and OpenBSD package builds failed. It is now declared in the Linux-only block that uses it. - TESTS: the logger thread-count scenario (
[logger][iso1]) runs in a forked child that starts with one thread, and first proves thread counting works by starting a helper thread. In the shared test process it skipped unless another test's thread happened to be lingering, which is exactly when the count was flaky (ASan CI:3 == 4). -
TESTS: two PostgreSQL-only scenarios (
[postgresql][media]) still expected the in-memory blob mirror to carry bytes after a write, which MED-6 removed; they failed only in thecoverageandpostgres-integrationCI jobs. They now assert the mirror records the size with no bytes, and check the bytes after a reopen reads them from PostgreSQL. -
FIXED: SQLite room snapshot no longer binds one parameter per event (DB-2). Federation worker room-snapshot relation queries now scope reads by
room_idvia a JOIN instead of building anIN (...)parameter list, so large rooms avoid the 128-parameter limit. - Close the remaining six 2026-09-29 security audit medium findings (AUTH-3, FED-6,
MED-5, DB-5, CSAZ-10 and DB-2).
docs/security-audit-report-2026-09-29.mdis updated to list them as fixed. - Update migration-count and last-migration assertions in unit and integration tests for migration 019 (
to_device_queue_age_bound). - TESTS: isolate the persistent-homeserver integration scenarios on per-test SQLite files instead of the shared in-memory backend, removing an ordering dependency that surfaced after migration 019.
- FIXED (ISO-2 regression): the federation worker's seccomp filter allowed every syscall after
tgkill. Thetgkillargument check added for ISO-2 skipped one instruction too many when the syscall was nottgkill, landing on the next entry'sRET ALLOW. Every syscall the allow list had not matched beforetgkill, includingkill,prlimit64,setrlimitandexecve, was therefore allowed. A mismatched tgid also fell through with the accumulator holdingargs[0]. The block now jumps over exactly its own instructions and returns the default action on a mismatched tgid. - FIXED (OUT-4): the remote media cache is consulted before the network. The cache was
looked up only inside
media::fetch_remote_media, which runs after discovery and the outbound request, so every client request for a remotemxc://URI still reached the origin.fetch_remote_media_livenow serves a cached record before taking an outbound budget slot. Also fixed: an expired entry was never removed and, being found first, made every later lookup for that key miss;remote_media_cache_max_entries = 0, documented as disabling the cache, removed its bound instead; a removed or quarantined record's entry is now erased on lookup. Newmedia::find_cached_remote_media. ADR-0114 records where the lookup must sit. - TESTS:
[out-4]integration scenario counts the requests a real HTTPS origin receives for two downloads of the same remote image (one, not two); the[out-4]unit scenarios now drive the pre-network lookup (hit, miss by key, expiry and re-admission, zero cap, removed record, LRU eviction) instead of the post-fetch admission step. - FIXED (MED-5): only the default port is dropped when deciding whether media is local.
The canonicalisation used
federation::strip_server_port, which drops any port, so a request forexample.org:8449(a different server name) was served this server's media. It now lowercases and drops a trailing:8448only. ADR-0115 corrected: it claimed both behaviours, and claimed a discovery-to-self check the code does not have. - CHANGED (MED-6): media quota defaults sized to memory. The defaults introduced
earlier on this branch (
10MiBper user) refused a single upload at the50MiBmax_upload_size, and charged all remote media from an origin to one@remote-media:<origin>owner, cutting the origin off after 10 MiB. Defaults are nowmax_total_size=2GiB,max_size_per_user=256MiB,max_records=100000. All media is held in memory, so the total is a memory budget. Remote media is exempt from the per-user quota and still counts toward the total and record caps. ADR-0113 updated;docs/user-manual.mddocuments the defaults and the two remote-media cache keys, and its media table, which a paragraph had split in two, renders as one table again. - TESTS:
[med-6]scenarios pin the defaults and their relationship (max_size_per_user >= max_upload_size, total ≥ per user), check that a user past the default quota gets 507 while another user is unaffected, and that remote media is exempt from the per-user quota but not from the server total. - FIXED (DB-5): a media upload that could not be stored is refused instead of answered
with an
mxc://URI.upload_local_mediadiscarded the results of the media-row and blob writes, so a database failure left the client holding a URI whose media vanished at the next restart, and the two rows were written in separate transactions. Newdatabase::commit_local_media_uploadwrites both in one transaction; on failure the upload answers 500 and newmedia::rollback_local_media_uploadremoves the record and releases its blob reference in memory, including for a deduplicated upload. The unusedpersist_blob_for_mediahelper is removed.docs/database-persistence.mdlists the revocation and upload write rules. - TESTS:
[db-5]media scenario fails the blob write of a new upload and the record write of a deduplicated one through the memory backend's statement-failure injection, and checks the response, memory and store, a successful retry, and that the earlier upload is still served. - FIXED (CSAZ-10): to-device rows from before migration 019 were dropped on the next
enqueue. Migration 019 gives existing rows
created_at_ms = 0. The enqueue path treated that as expired and deleted the row as soon as another message for the same device arrived, while the drain path treated it as fresh. Both now treat an unknown age as not expired; the per-device count cap still bounds such rows. - DOCS (CRY-2, ISO-2):
federation.worker.ipc_max_dispatch_queue_countandipc_max_dispatch_queue_bytesare documented indocs/user-manual.mdand the example config;docs/hardening.mdanddocs/threat-model.mddescribe the dispatch-queue bound and the worker signal/limit restriction, with the shared-uid residual; ADR-0112 records how an argument-checked entry must be laid out in the generated BPF program and that it needs a real-kernel test; ADR-0065 points to ADR-0111; ADR-0111 and ADR-0112 dates corrected. - FIXED (ISO-2 regression): a hardened federation worker was killed at startup. The ISO-2
change removed
setrlimitandprlimit64from the worker filter, but the worker's event loop callsstart_runtime(), which re-applied the main-process hardening profile and itssetrlimit(RLIMIT_CORE)after the worker filter was installed:SIGSYS, and the supervisor restarted the worker in a loop, taking federation down. The allow-all bug above hid this. NewRuntimeStartOptions::process_hardening_applied_by_caller, set by the worker whenfederation.worker.apply_hardeningis on, skips the main profile.prlimit64is allowed again only to read the worker's own limits (pid 0 or its own pid, NULL new limit): glibc'sgetrlimit()isprlimit64, and thread creation uses it. The BPF builder now derives each argument-checked entry's skip distance from the block's size instead of a hand-counted constant.src/federation_worker/AGENTS.mdrecords the constraint. - TESTS:
[iso2]also checks thatprlimit64reading the process's own limits andgetrlimit()are allowed while setting a limit or reading the parent's is denied. The[iso-2]list test expectsprlimit64in the worker list, as it already did fortgkill, since both are argument-checked.[federation-worker][seccomp]passes again. - FIXED (CSAZ-10): per-user caps on E2EE key and filter uploads. New
server.client_api.max_one_time_keys_per_device(1000),max_key_signatures_per_user(10000) andmax_filters_per_user(1000). An upload that would cross one is refused with400 M_TOO_LARGEand stores nothing. An identical filter (canonical JSON) returns the existingfilter_idinstead of a new one, and new filters are stored canonically. ADR-0118. - FIXED (CSAZ-10): one fallback key per algorithm per device. The spec says the server "will only persist one key per algorithm"; each fallback-key upload with a new key ID added a row. A new fallback key now replaces the previous one of its algorithm in one transaction.
- CHANGED (CSAZ-10):
/keys/queryreads key signatures through an index by target user (database::key_signatures_for_target) instead of scanning every signature upload; a stale index falls back to a scan. - TESTS: new
[csaz-10]unit filestest_fallback_key_single.cpp,test_otk_cap.cpp,test_key_signature_cap.cpp,test_filter_cap.cpp,test_key_signature_index.cpp, shared fixturetests/support/e2ee_caps_support.hpp, and config scenarios intest_config_operational_limits.cpp. - DOCS: the audit report's "Remediation status" records all 31 medium findings as
fixed, with what was corrected in the first-round fixes and the residual notes (ISO-2
shared uid, MED-5 no discovery-to-self check); its earlier claim of such a check is
removed.
docs/todos/capability-gaps.mdcloses the medium remediation section, whose table had marked AUTH-3 complete. - TESTS:
[session_restart]waits until the wall clock, which token expiry is measured against, is past the token's expiry instead of sleeping a fixed 1.5 s. WSL2 can step the wall clock back by over a second; a full-suite run and 1 in 20 isolated runs failed because the sleep ended before the expiry. The assertion is unchanged. - TESTS: the worker-seccomp TSYNC scenario no longer skips. The skip added with the
regression above masked it: the pre-existing thread's
execvesucceeded because the filter allowed it, not because of a kernel quirk. New real-kernel scenario[iso2]installs the worker filter in a forked child and checks thatkillandtgkillaimed at the parent andprlimit64are denied,tgkillon its own thread group is allowed, and syscalls listed aftertgkillare still matched correctly.
0.12.18¶
- Remove the retired September 2026 audit report and its documentation navigation entry; retain the later 29 September report and remediation ADRs.
- Raise normal client and federation traffic budgets and sync/page defaults; expose HTTP parsing, sync admission, API scan/page/session, recovery, cache, outbound queue, push and application-service budgets as validated deployment settings.
- Raise per-address open-connection admission from 64 to 256 for clients sharing an address, while retaining global dispatcher and worker bounds.
- Raise
/state_idsstate/auth list caps from 1,000 to 65,536 each, while bounding each incoming PDU's cumulative recovery work by a shared call budget and deadline. Recovery still depends on remote availability and local event history. - Make per-user notification retention configurable and raise its default from 200 to 1,000 entries, independently of push delivery.
- Make state-resolution group, key, mainline and auth-walk budgets configurable; allow 65,536 entries per group and enforce an aggregate 131,072-entry default before resolving forks.
- Replace the conflicted-event duplicate scan with hash indexing while preserving first-occurrence order, avoiding quadratic collection work at larger resolver capacities.
- Honor the configured federation transaction byte cap at the HTTP layer and size worker IPC frames for the configured request and response caps.
- Require restart for startup-snapshotted federation/client limits rather than claiming that SIGHUP applies them.
- FIXED: application-service sender_localpart users are created from the loaded registry at startup (AUTH-3). The startup loop that ensures each registered appservice has a real
sender_localpartuser was iteratingruntime.appservicesbefore the registry had been moved from the temporaryloaded.registry, so no sender users were ever created. It now walks the loaded registry directly. - FIXED: federation
make_join/make_leave/make_knockreject a{userId}that is not on the origin server (FED-6).handle_make_membershipnow compares the path parameter's domain with the verified requestingorigin; a mismatch returns 400M_INVALID_PARAM. - FIXED: media downloads canonicalise the local server name before routing (MED-5). Host case and the default federation port (
:8448) are now normalised so variants such asEXAMPLE.ORG:8448are treated as local media rather than fetched remotely. - FIXED: token revocation paths fail closed on persistence failures (DB-5).
revoke_*helpers return a row count, so zero is ambiguous between "no rows" and "the backend write failed". Every auth-service revocation path now verifies the persistent-store state after the write and returns 500 if any targeted token is still unrevoked. A test-only memory-backend seam lets unit tests force a named revocation statement to fail. ADR-0116. - FIXED: per-recipient to-device queues are bounded by count and age (CSAZ-10).
to_device_messagesgains acreated_at_mscolumn, migration 019, and configurableserver.client_api.max_to_device_messages_per_user_device/to_device_message_ttl_seconds. Enqueue evicts expired rows then the oldest rows until the new message fits; drain also purges TTL-expired rows. This prevents non-existent devices from accumulating unbounded queue state. ADR-0117. - FIXED: federation worker room-scoped reads trust main's verified identity. When main forwards an inbound request to the worker over authenticated IPC, the X-Matrix signature has already been verified and server/discovery/trust policy applied. The worker now synthesizes a minimal remote record from the verified
origin/key_idinstead of re-resolving the peer, allowing room-scoped reads to be served (and fallback-tested) without a redundant network resolution step. Direct, unverified requests keep the full fail-closed resolution path. ADR-0110. - TEST-ONLY:
handle_local_http_requestsupportsPUT /_matrix/client/v3/rooms/{roomId}/state/{eventType}[/{stateKey}]. This lets integration tests seed room state events (e.g.,m.room.server_aclandm.room.history_visibility) through the local-router seam used by federation-worker tests. - TESTS: new
tests/integration/test_security_audit_worker_snapshot_flow.cppverifies that a failed worker room reload falls back to main's authoritative state, so a stale allow-ACL cannot outlive a fresh deny-ACL.tests/integration/test_security_audit_federated_to_device_flow.cppis now registered and passing. - FIXED: inbound federation to-device EDUs with an empty message type are no longer reported as accepted.
enqueue_direct_to_device_messagesnow treats an empty per-messagetypeas an invalid envelope, and the EDU sink returnsrejected_invalidinstead ofacceptedwhen nothing can be stored. Existing tests that delivered to a local user without a matching device now register the device so the end-to-end delivery assertion actually exercises the store path. - Tighten secure defaults for configurable operational limits.
sliding_sync_max_timeline_limitdefaults to 100,sliding_sync_max_room_subscriptionsandsliding_sync_max_required_state_entriesto 256; HTTP sync admission defaults to 4 per user / 2 per device; federation pending-join queues default to 32 PDUs and 512 KiB. These match the security-audit integration tests and limit amplification surfaces out of the box. - TESTS: the draft security-audit integration tests are moved from
build-wsl/drafts/totests/integration/, registered intests/meson.build, and passing; newtests/unit/test_config_operational_limits.cpp,test_configurable_client_limits.cpp,test_configurable_federation_limits.cppandtest_configurable_http_limits.cppare added to the suite. - FIXED: reloadable smoke test uses a genuinely reloadable key. The
server-plan-config-reload-reloadable-smoke-testintests/smoke/meson.buildchangedsecurity.federation.remote_timeout, but federation fields now correctly reportrestart_required. It now changessecurity.media.local_upload_policy, which is reloadable, so the smoke test once again verifies the reload path instead of a restart-required path. - FIXED: inbound federated to-device messages addressed to device
*are delivered again. The known-device filter (ADR-0107) matched*literally against registered devices, so every wildcardm.direct_to_devicemessage (for examplem.room_key_requestand its cancellations) was silently dropped while the transaction returned 200. The Server-Server API defines*as "all known devices for the user"; it is now expanded into one queued row per registered device of the target user, as the client-serversendToDevicepath does, and each expanded row counts against the per-EDU delivery cap. - FIXED: E2EE key routes keep their rate limit and their own bucket. Route normalization (ADR-0092) sent
POST /keys/claimandPOST /keys/uploadto the shared{unmatched}fallback, and the built-in refinements were matched against the normalized route only. Both routes therefore lost their 120/min cap (falling to the 600/min generic default, which made one-time-key draining easier) and shared one bucket with every unknown path. Built-in per-IP and per-user refinements now also match the original path, and the matcher gains templates for every implemented route that was falling back:keys/claim,keys/upload,room_keys/version/{version},room_keys/keys/{roomId}[/{sessionId}],rooms/{roomId}/unban,user/{userId}/rooms/{roomId}/tags[/{tag}],presence/{userId}/status,login/sso/redirect/{idpId}and the twoim.nheko.summaryroutes. - FIXED: login no longer dereferences a missing dummy password hash. The Argon2id snapshot in
login_local_userdereferenceddummy_password_hash()unconditionally for an unknown user, although it returns null when the dummy hash could not be created; GCC 16 rejected this with-Werror=null-dereference(Fedora RPM build). The snapshot is now anstd::optional, and a login with nothing to verify against is refused as invalid credentials, as before the snapshot was introduced. - FIXED: tests build on the libc++ of FreeBSD and OpenBSD again. Four new scenarios used
std::jthread, which that libc++ does not provide; they now usetests/support/joining_threads.hpp, which gains an explicitjoin()for scenarios that assert after the thread has finished. - FIXED:
merovingian-httpdeclares its link tomerovingian-core.outbound_client.cppnow usescore::SocketHandle, so targets linkinghttp_libwithoutcore_lib(thefuzz-srv-recordfuzz target) failed to link. -
TESTS: the
%2e%2epath-preservation check intest_security_audit_outbound_flow.cppcompares the request line case-insensitively. libcurl 8.20 and later send percent-encodings with upper-case hex digits (equivalent under RFC 3986), which failed the exact-byte comparison on openSUSE, NetBSD and the coverage job's bundled curl; the dot segment is still required to reach the peer unresolved. -
FIXED: IPC dispatch queue is bounded (CRY-2).
IpcChannel::dispatch_queue_previously grew without limit on a fast producer, allowing memory exhaustion. The queue now drops the oldest stale frame once a configurable cap is reached and counts the drops. ADR-0111. - FIXED: federation worker syscall profile is tighter (ISO-2). The worker seccomp allow-list no longer permits
kill,tkill,setrlimit, orprlimit64;tgkillremains allowed only when its thread-group argument matches the worker's own TGID. Landlock rulesets now request ABI-6 signal scoping on capable kernels. ADR-0112. - FIXED: media quotas default to operational bounds and blobs are held once in memory (MED-6).
security.media.max_total_size,max_size_per_user, andmax_recordsnow default to1GiB,10MiB, and100000instead of zero. After hydrating the runtime repository fromPersistentStore, the persistent copy of blob bytes is cleared so only one in-memory copy is held. ADR-0113. - FIXED: remote media downloads are cached by origin and media ID (OUT-4). Fetched remote media is keyed by
(origin_server, media_id)with a configurable TTL and LRU eviction, so repeated requests serve the already-stored local record instead of re-fetching and re-storing. ADR-0114.
0.12.17¶
-
Pages integration and release version. Preserve the merged 0.12.16 documentation site and update all live binary/package versions to 0.12.17. Four audit ADR links now use the actual published anchors. The merged full suite passes 55 groups with no failures or timeouts; strict Pages build, 8 package checks and 20 documentation-tooling checks pass.
-
FIXED: a password change during Argon2id verification no longer issues a session.
login_local_userused the password hash snapshot taken before releasingruntime.mutexand ignored any concurrent password change until after the session was minted. It now re-finds the user and re-checks the stored hash after re-acquiring the lock, so a password invalidated mid-login fails with403 M_FORBIDDEN. ADR-0104. -
FIXED: sendToDevice no longer creates queue state for unknown local recipients. The client-server
PUT /sendToDeviceendpoint previously enqueued ato_device_messagesrow and advanced the sync stream ID for any syntactically valid local(user_id, device_id), including non-existent users and devices that did not belong to the named user. It now silently discards those deliveries while still fanning out wildcard*to the target user's registered devices. ADR-0105. -
FIXED: MSC4186 to_device pagination no longer acknowledges undelivered rows. The sliding-sync
to_deviceextension reported the global sync watermark asnext_batch, causing the next request to delete every earlier device-targeted row even if the previous response was truncated or lost. The continuation token is now the stream ID of the last message actually included in the response, and an empty response keeps the previous position. ADR-0106. -
FIXED: inbound federation to-device EDUs no longer queue unknown local devices.
m.direct_to_deviceEDUs in/_matrix/federation/v1/send/{txnId}previously advanced the sync stream ID for every syntactically valid target device, even when the device did not exist or did not belong to the named local user. The inbound path now silently discards those deliveries. ADR-0107. -
FIXED: federation worker room-sync failures fall back to main. The federation worker's PersistentStore is a startup snapshot, and
room_syncnotifications were fire-and-forget: a reload failure left the worker serving stale room state. Main now assigns a per-room generation to eachroom_syncnotification, the worker reportsok/failedback via a newroom_sync_resultIPC frame, andFederationProxyfalls back to the authoritative main-process state when the last observed sync failed. ADR-0108. -
Log records cannot be forged by control characters (AUTH-9). A JSON
\nin a loginidentifier.userordevice_idwrote an extra physical log line, and ESC or a C1 CSI reached the operator's terminal raw.SingleLog::make_log_linenow passes the module and message of every console and file line through the newescape_log_controls:\n,\r,\tbecome those escapes, other C0 controls and DEL become\xHH, UTF-8 C1 controls become\u00HH, and all other bytes (including UTF-8) pass through. ADR-0102. -
FIXED: a sensitive last field joined the next log record onto its line.
redact_log_messagesplit tokens on spaces only, so when a line ended in a sensitivekey=valueits replacement also discarded the record's\n. It now treats every whitespace character as a token boundary and keeps it. -
TESTS: new
tests/unit/test_security_audit_log_controls.cppasserts at the real synchronous console sink that controls in values, keys and event names are escaped (C0, DEL and C1), each call is one physical record, and redaction still holds.test_observability.cppgains a scenario for the terminator surviving a redacted last token. -
Directory durability and authenticated lookup (CSAZ-5). Schema 18 stores publication independently of join rules and defaults previously unrecorded publication to private. Creation persists it with initial membership; PUT commits before updating memory. POST publicRooms runs after authentication and account-state checks, including remote lookups. ADR-0099.
-
Presence sharing and input bounds (CSAZ-8). Sync presence is restricted to current joined peers. PUT requires the specified presence enum and optional string status, bounded locally to 1024 UTF-8 bytes before stream allocation. Invalid updates preserve the stored row and sync watermark. ADR-0098.
-
Atomic administrative media writes (DB-5, partial). Quarantine, release and removal commit metadata, optional blob changes and required audit rows together before repository state or metrics change. Failed writes return 500. Upload and authentication token/account write-failure paths remain open. ADR-0100.
-
Large PostgreSQL room snapshots (DB-2, partial). Scoped relation queries join events using one room parameter rather than one parameter per event. A real two-handle regression reproduces the old 128-parameter failure. Generic failed worker reload handling remains open. Before Pages integration, the completed full suite passed 54 groups (0 failures/timeouts); focused security flows passed 491 assertions in 13 cases, and real PostgreSQL regressions passed 256 assertions in 2 cases.
-
Outbound authority and socket pin agreement (OUT-1, OUT-2). Matrix server names reject malformed authorities before discovery. Outbound and media-redirect URLs share libcurl's strict URL parser; every actual socket destination must match an approved numeric address and port. Proxies and connection reuse cannot bypass the check. Real TLS tests cover multiple pins, IPv6, encoded paths and refused authorities. ADR-0097.
-
Complete mainline ancestry and bounded auth graphs (EVT-5, EVT-9). State resolution loads power-level ancestors through the event source and refuses incomplete or cyclic ancestry. Room-v12 conflicted-subgraph work counts distinct events and traverses the shared DAG once. Corrected conformance fixtures reproduce both old defects; the combined authorization and state-resolution checks pass 20,994 assertions in 129 cases. ADR-0095.
-
Remote quarantine enforcement (MED-2). Held remote media returns 451 without payload bytes and cannot enter thumbnail processing. Storage admission no longer counts as successful delivery; response conversion refuses unexpected internal success statuses. Repository and real-HTTPS regressions failed before the fix and pass afterward in the parent checks.
-
Worker restart recovery (ISO-3). A failed spawn no longer ends supervision or calls
waitpid(-1). Retries retain exponential backoff, reset it only after sustained child/IPC health, and remain interruptible during shutdown. A real-child regression reproduced permanent loss of recovery before the fix; the focused case passes 28 assertions. ADR-0096. -
Sync long-poll admission and overflow-safe timeouts (HTTP-4). v3 and sliding sync share per-account and per-device admission, including queued waits. Excess requests and refused handoffs return 429 instead of occupying the main request pool. Oversized decimal timeouts saturate before applying the existing 120-second ceiling. The transport regression passes 224 assertions in one focused case; the combined suite also passes.
-
Durable media privacy and re-upload removal (MED-1, MED-3). Restart restores legacy endpoint visibility instead of exposing new uploads. Re-uploading removed bytes revives their single storage identity, so live lookup, persistence and later removal address the same blob. Restart regressions reproduced both defects before the fixes and pass afterward.
-
WIP: medium-severity findings from the 2026-09-29 security audit. The report contains 31 distinct medium-severity findings, not 20. Their per-finding status is tracked in
docs/todos/capability-gaps.md; this branch does not yet resolve all of them. The verified first batch passed 157 unit assertions in 16 cases, 367 integration assertions in 10 cases, and 20,422 state/creator conformance assertions in 18 cases. The completed fresh full suite passes 54 targets, with 0 failures and 0 timeouts (exit 0). All new regression tags executed. -
FIXED: implicit v12 creator privileges in power ordering (EVT-8). The sorter resolves the room's implicit create event through the existing event source and preserves candidate-specific power-level ancestors. Both the original and additional creators sort at infinite power; missing create events fail closed. A new BDD conformance regression failed before the fix and passes afterward; the focused state-resolution suite passes. Full-suite verification of the combined medium-fix branch is now green.
-
PARTIAL: published directory visibility (CSAZ-5).
/publicRoomsexcludes rooms not explicitly published, and publiccreateRoomvisibility sets the directory flag. Focused directory and creation tests pass (672 assertions in 14 cases). Durable visibility and authentication on POST remain outstanding; CSAZ-5 is not yet closed. -
Stripped invite/knock room summaries (CSAZ-7). Generated invitation snapshots and client reads now retain only the four stripped-state keys, seven room-summary types, and the recipient's own membership. Legacy/federated snapshots are re-pruned and deduplicated on read; invitation-time decoration is preserved. Sliding-sync invite reads share the same protection. The restrictive allowlist is a disclosure policy, not a protocol prohibition (ADR-0088). After the failing baseline, independent combined-tree focused verification passed 1,286 assertions in 17 cases; full-suite verification is now green.
-
Federation membership validation, receipt ACLs, and joined-subject admission (FED-6, FED-8).
send_join,send_leave, andsend_knockreject events whose membership, type, sender/origin, state key, or room/event identifiers do not agree with the request. The mutation sink repeats the checks and derives persisted membership from the event. Receipt EDUs check the authenticated origin against each room's ACL independently, retaining allowed rooms in mixed batches, and now also require the receipt subject to be a joined member of that room. Parent verification passed 667 assertions in 45 focused federation cases; the new joined-subject regression adds 67 assertions in 2 focused cases; combined full-suite verification is now green. -
Unsolicited PDU admission and bounded pending joins (FED-11). The common sink refuses uninterested-room PDUs before stream allocation, backfill, cache mutation or persistence. Current local join/invite/knock membership permits normal processing; remote-only, departed and banned memberships do not. Outbound joins establish RAII reservations and defer concurrent PDUs in bounded transient queues (32 rooms, 32 PDUs and 512 KiB JSON per room), deduplicating event IDs and refusing overlaps/excess. Failed or throwing joins discard their queues. Successful joins drain through the common sink after committing verified state and membership, outside every global-lock recursion level. Solicited bootstrap/backfill paths remain explicit (ADR-0089). Independent focused unit, lifecycle, and existing join/backfill verification passed; full-suite verification is now green.
-
FIXED: restricted-join authorising-server signature verification (EVT-1). The restricted/knock-restricted join authorization rule now requires a signature from the authorising user's homeserver in the event's
signaturesobject. Federation PDU ingestion cryptographically verifies that signature after resolving the authorising server's key, and state resolution enforces the structural presence of the signature without network access. -
FIXED: application-service foreign-user assertion (AUTH-6).
appservice_owns_usernow rejects anyuser_idwhose domain is not the local homeserver before matching namespace regexes, preventing a compromised appservice from having this server sign events as a foreign user. -
FIXED: application-service sender_localpart reservation (AUTH-3). Every appservice's
sender_localpartuser is created at startup if missing, and ordinary/registerand/register/availablereject that localpart as reserved. -
FIXED: room-version-aware creator authorization (EVT-7). v1-v10 creator identity comes from
content.creator; v11+ uses only the create event sender, strips client-supplied legacy creator content on local room creation, and no longer treats the create sender as joined for ordinary sends or power edits. Creator bootstrap joins require exactly the authoritative create event as their sole predecessor; v12 derives that ID from the room ID, and additional creators do not receive implicit membership. EVT-7 conformance passed 108 assertions in 9 cases; related auth conformance passed 467 assertions in 97 cases. -
WIP: complete state-resolution mainlines and bounded v12 conflicted subgraphs (EVT-5, EVT-9). Mainline ordering now fetches each required power-level ancestor through the shared event source and fails closed on missing, malformed, cyclic, or depth-truncated chains. The v12 subgraph walk processes each distinct reachable auth event once, rejects cycles and malformed/missing nodes, and applies the existing auth-chain event cap. Focused conformance verification is pending.
-
FIXED: bounded Argon2id admission (AUTH-4). A process-wide counting semaphore caps the number of concurrent password and registration-token Argon2id verifications. When saturated,
/login,/register, and/register/m.login.registration_token/validityreturn 429 /M_LIMIT_EXCEEDEDbefore any memory-hard work runs, and shed requests do not count toward the failed-login lockout. Verification still runs outside the global runtime mutex. ADR-0090. -
PARTIAL: sync timeout cap and rate-limit key normalisation (HTTP-3, HTTP-4). Rate-limit policy lookup uses the normalised route so varying path/query segments share buckets.
/syncand sliding-sync long-poll timeouts are capped at 120 s. HTTP-4 still needs per-user long-poll admission. HTTP-3 needs dedicated bypass regression verification. -
HTTP-3 route-bucket normalization and bounded eviction. Known dynamic Matrix paths use finite route templates, unknown paths share one fallback bucket, and policy lookup still honors original operator prefixes. The bounded bucket tables now evict the least-recently-used entry in O(1) expected time. BDD regressions cover media, directory, room event/state, relations, to-device, account-data, unknown paths, and LRU behavior. Focused verification is pending.
-
DB-3 explicit database selection. PostgreSQL startup now fails when its URI file is missing, unreadable, or empty instead of silently opening an ephemeral store. A programmatic-only memory backend is available to test fixtures and is not accepted by config parsing; non-persistence fixtures explicitly select it through the shared helper. Database-related focused verification and the pre-Pages combined suite passed.
0.12.16¶
-
NEW: documentation site on GitHub Pages. The documents under
docs/are published with MkDocs Material at https://omg-software.github.io/Merovingian/, configured bymkdocs.yml. The navigation now covers every published document: architecture and module design docs, the ADR register, security docs and audit reports, operations, development and dependency reviews, the Matrix v1.19 Client-Server API reference, the road to 1.0, and this changelog. Agent instruction files, the vendored Matrix spec mirrors, superseded generated references and working notes are excluded. The landing page's Material icons now render (pymdownx.emojiwas missing). ADR-0101. -
NEW: the site is built on every branch and deployed from
main..github/workflows/pages.ymlruns on every push and pull request with no path filter. It runstests/tooling/test_docs_site.py, thenmkdocs build --strictwith link and anchor validation, and uploads the rendered site as thedocs-siteartifact. Only a push or manual run onmaindeploys to Pages, and only the deploy job holdspages: writeandid-token: write. -
NEW: repository links work on the site.
scripts/mkdocs_site_hooks.pyrewrites links to files the site does not publish (source, workflows, moduleAGENTS.mdfiles) so they open on GitHub at the commit the site was built from, and links intodocs/matrix-v1.19-spec/so they open the same section ofhttps://spec.matrix.org/v1.19/. It also publishesCHANGELOG.mdas the changelog page and stamps the footer with themeson.buildversion. Before this the strict build failed with 42 broken-link warnings. -
SECURITY: documentation dependencies are hash-locked and patched.
mkdocs-materialmoves from 9.6.20 to 9.7.7, fixing the search-suggestion DOM XSS GHSA-xvg9-69gf-fjrf that failed the dependency triage gate.requirements-docs.txtis now a hash-locked resolution ofrequirements-docs.incovering every transitive package, and CI installs it withpip --require-hashes. New dependency review:docs/dependencies/mkdocs.md. -
FIXED: malformed headings.
docs/media-repository.mdanddocs/todos/capability-gaps.mdbegan with#Title, which is not an ATX heading, so neither had a title. -
TESTS: new
docs-site-toolingsuite (tests/tooling/test_docs_site.py): workflow triggers, deploy gating and permissions; hash-locked dependencies and the minimum safemkdocs-material; every published document having a navigation entry; exclusion of agent and spec-mirror files; and the link rewriting hook.
0.12.15¶
-
FIXED: critical logs lost when the process exits after hardening refusal (LOG-1).
LOG_CRITICALmessages now bypass the asynchronous writer queues and are written synchronously to console and file, so startup/hardening refusal lines are visible before the main thread exits. ADR-0087. -
FIXED: heap-use-after-free in outbound federation signing (CRY-2).
federation::OutboundCallnow owns its copy of the Ed25519 signing secret in acore::SecretBufferinstead of borrowing a span fromruntime.database .signing_secret_key. Every caller copies the secret while holdingruntime.mutexand moves it into the call, so releasing the mutex around the network round trip can no longer dangle on a rotated key. ADR-0086. -
FIXED: OpenBSD build failure in TLS layer (TLS-1).
src/homeserver/tls.cppnow detects LibreSSL at compile time and omits theSSL_has_pendingcall that is not present in the OpenBSD base system. -
FIXED: FreeBSD/NetBSD build failure in sync history-visibility tests (SYNC-1).
tests/unit/test_sync_history_visibility.cppnow explicitly includes<algorithm>sostd::ranges::findis visible on libc++ implementations that do not pull it in transitively. -
FIXED: OpenBSD/NetBSD test failures caused by platform limits (BSD-1).
tests/unit/test_logger_writers.cppskips its thread-count assertions when/proc/self/taskdoes not enumerate process threads reliably, andtests/unit/test_net_connection_parker.cppno longer requires 200 concurrent socketpairs to all be accepted on runners with tight per-process file-descriptor limits. -
FIXED: OpenBSD integration-test timeout flake (BSD-2).
tests/meson.buildraises theintegration-teststimeout from 600 s to 900 s; the emulated OpenBSD runner frequently takes ~580 s and was hitting the old limit. -
FIXED: worker-pool starvation via idle and slow connections (HTTP-1) and keep-alive lifetime limits (HTTP-8). New
net::ConnectionParkerandhomeserver::HttpConnectionDispatcherhold every connection that is not being served on onepoll(2)thread and dispatch it to the main request pool only once it is readable. A single client address can hold at mostmax(1, request_threads / 4)workers at once (trusted reverse proxies are exempt). Request bodies are read under a continuous minimum rate of 16 KiB/s after a 10 s grace. Media uploads above the transport cap authenticate the request head before any body bytes are read. Connections are closed after 1 000 requests or one hour of lifetime. New config keyserver.http.request_threads(4..256, default 16) sizes the main pool and is wired to the client-triggered outbound proxy caps so they stay at half the pool. ADR-0077; amends ADR-0072. -
FIXED: state-resolution and event-authorisation divergences (EVT-2, EVT-3, EVT-4, EVT-6).
m.room.power_levelsis now read from the resolved room state, not from the most recent event with that type. Negative per-user power levels are honoured. State resolution correctly handles partitions with three or more groups and applies Kahn topological ordering to power-level events before the mainline tie-break. -
FIXED: SIGPIPE handling and thread-start ordering (HTTP-5, ISO-1). Every executable entry point ignores
SIGPIPE. No thread is started before process hardening and seccomp are installed, and seccomp filters are applied withTSYNC. ADR-0082. -
FIXED: federation worker signing oracle (CRY-1). The federation worker no longer carries a signing provider; the main process refuses
sign_requestframes from the worker. ADR-0078 supersedes the relevant part of ADR-0015. -
FIXED: bad-signature backoff charged to the source address, not the claimed origin (FED-4). Failed signature verification now accrues backoff against the connecting peer's address and the backoff decays. ADR-0081.
-
FIXED: PostgreSQL
bytearound-trip (DB-1).byteacolumns are decoded from the server's hex form on read and bound as binary on write, so the signing key and other binary rows survive a restart on PostgreSQL. -
FIXED:
send_joinstate binding and own-domain verification (FED-1). Thestatearray of asend_joinresponse is bound to the room being joined. Events whose sender domain matches the local server are now verified against our own signing keys, and the federation resolver refuses to fetch keys for our own server name. ADR-0083. -
FIXED: federation room reads and missing-events bounds (FED-2). Federation endpoints that return room events or state now require the origin server to be a member of the room or for the room to be world-readable, and
/get_missing_eventsis bounded. -
FIXED: audit-row flooding and retained prepared statements (AUTH-1, AUTH-11). Unauthenticated rejection paths that write audit rows are rate-capped, use a bounded in-memory window (1 024 rows) and 255-byte fields, and no prepared statements are retained across calls. ADR-0080.
-
FIXED: to-device sender binding, inbound invite authorisation and device-list fan-out (FED-3, FED-5, FED-7). To-device EDUs bind the sender, are deduplicated by
message_id, and cannot overwrite a ban. Inbound invites are authorised against room state before acceptance. Device-list EDUs fan out only to sharers of the room, deduplicated. ADR-0085. -
FIXED: sliding-sync room access and private receipts (CSAZ-1, CSAZ-4). Sliding-sync subscriptions are refused for rooms the user cannot access, and
m.read.private/m.fully_readreceipts are returned only to their owner. -
FIXED: client read gates and history visibility on every read path (CSAZ-2, CSAZ-3).
initialSync,/members,/state,/messages,/context,/event,/search,/relations,/threads,/syncand sliding sync now judge history visibility from the room state recorded at each event and apply room-read access checks. Banned users see state as of the ban. ADR-0084. -
FIXED: client-triggered outbound budget and remote-media fetch gate (HTTP-2, OUT-7).
publicRooms?server=, remote room-alias lookups and remote media downloads now take a slot in a bounded in-flight budget before any outbound call and run under a short deadline.security.media.remote_fetch_enabled=falsenow blocks remote media fetches before discovery. ADR-0079.
Operator- and client-visible behaviour changes in 0.12.15¶
- History visibility is now enforced on every client read path. Pre-state-group history is visible only to users who were joined when the event was sent.
- Banned users see room state as of their ban event.
- Sliding sync enforces
timeline_limit≤ 100 and caps subscriptions andrequired_statepairs to 256 each. - Client-triggered outbound proxy calls return
429 M_LIMIT_EXCEEDEDwhen the in-flight budget is exhausted. security.media.remote_fetch_enabled=falsenow really blocks remote media fetches and thumbnails.- Inbound
m.receiptevents now accept onlym.read; other receipt types are rejected. - The unauthenticated audit window is 1 024 rows.
- New configuration key:
server.http.request_threads(4..256, default 16, restart required).
0.12.14¶
- DOCS: second full security audit (September 2026).
docs/security-audit-report-2026-09-29.mdrecords a sequential, area-by-area audit of eleven high-risk surfaces against the Matrix v1.19 spec. Each area had a specialist auditor and an independent adversarial verifier, and every high or critical finding was re-checked in code. - Totals: 101 verified findings: 1 critical, 23 high, 31 medium, 46 low.
- Most serious:
- Any local user can take over a room:
send_joinresponse state is not bound to the room being joined, and events claiming our own domain are accepted without a signature check (FED-1). - Federation read endpoints and sliding sync perform no membership or history-visibility checks (FED-2, CSAZ-1, CSAZ-3).
- Several auth-rule and state-resolution divergences from the spec (EVT-1 to EVT-6).
- A compromised federation worker can have the main process sign arbitrary bytes, and its logger threads escape seccomp and Landlock (CRY-1, ISO-1).
- The 8-thread request pool can be starved by one client (HTTP-1).
- On PostgreSQL the signing key cannot be read back after a restart; confirmed against a live PostgreSQL 16 (DB-1).
- Any local user can take over a room:
- Recommended fix order and doc corrections: the report gives the order to fix in and lists the documentation statements the code contradicts.
- No production code was changed.
0.12.13¶
-
FIXED: build failures on newer Linux kernel headers and on the BSDs.
landlock_ruleset_attrgained members (scoped,quiet_access_*) that the designated initializer inlandlock_hardening.cppleft out under-Werror; it is now value-initialised, so every byte the kernel does not understand is zero, as the kernel requires.client_address.cppnow includes<sys/socket.h>forAF_INET/AF_INET6(FreeBSD, OpenBSD), and the Landlock ops table and its access-rights helper are compiled on Linux only (FreeBSD, NetBSD). OpenBSD has noRLIMIT_AS, so the thumbnail decoder's memory cap isRLIMIT_DATAthere, which on OpenBSD also bounds anonymousmmap(media::decoder_memory_limit_resource()); skipping the cap would have weakened a fail-closed control. Two concurrency tests usedstd::jthread, which the libc++ on FreeBSD and OpenBSD lacks; they now usetests/support/joining_threads.hpp. NetBSD linked pkgsrc's OpenSSL (libssl.so.3) while pkgsrc'scurlandpostgresql17-clientlink the base OpenSSL (libssl.so.16), so the werror link refused two OpenSSLs in one process (this also failed onmain). The NetBSD CI and package jobs, thepkgindeveloper setup and the pkgsrc scaffold no longer pull in pkgsrcopenssl, so the base OpenSSL is the only one. The test TLS servers leaked every socket they accepted (TlsConnectionborrows its descriptor; they closed it only on a failed handshake), about one per outbound call, until OpenBSD's low descriptor limit broke later tests; they now own it in acore::SocketHandle, as the production server already does, and[descriptor_leak]guards it. CI test fixes: the decoder hardening scenarios assert the sanitizer build's own contract (it skipsRLIMIT_ASand the seccomp filter), and the PostgreSQL role scenarios make only the newest migration pending, not a gap. -
REMOVED: dead
select_auth_eventsandAuthChainhelpers. They had no production callers, andselect_auth_eventsnamedm.room.join_rulesfor every membership, against the spec. Their three scenarios, the last intests/conformance/test_event_authorization.cpp, went with them and the file was removed. The rules they encoded are now covered against the code that creates events: the v12 create-event exclusion in[auth_events_selection], and the third-party invite rule in thethird_party_signedjoin scenario ([3pid]). -
DOCS: stale "membership path lacks receipt checks" statements corrected.
src/federation/AGENTS.mdrule 5 and the capability-gaps soft-fail row still saidsend_join/send_leave/send_knockskipped the three receipt checks; they have run them since ADR-0064 phase B2 on this branch. A newly found gap, restricted-room joins through this server as the resident, is recorded indocs/todos/capability-gaps.md. -
FIXED: leaves, kicks and bans named
m.room.join_rulesin theirauth_events(MEDIUM, federation). Auth events selection namesjoin_rulesfor a membership of join, invite or knock only, and auth rule 3.2 rejects anyauth_eventsentry the selection does not name. Every leave, kick and ban this server created, and every leave built from itsmake_leavetemplate, carried it and was therefore rejected by conformant servers (and, for remote leaves, by this server). Bothauth_events_for_roomand the membership template now follow the selection. Tests:[auth_events_selection]intests/conformance/test_client_server_conformance.cppandtests/conformance/test_pdu_ingestion_conformance.cpp. -
FIXED: a backfilled event naming a stored event's ID skipped verification (LOW).
verify_and_store_backfilled_eventtreated any event whose JSON"event_id"field named a stored event as already verified. The field is the origin's to write, so a forged/event_authchain entry reusing a stored event's ID, with a membership the signature does not cover, was accepted and let an unverified snapshot through. The shortcut now also requires the stored event to be the same event (equal reference hash). Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([event_auth_forged_id]). -
FIXED: backfill stored whatever an
/event/{eventId}fetch returned (LOW). Both fetchers (per missing reference, and for/state_idssnapshots) verified and stored the returned event without checking it was the one requested, so an origin could plant any validly signed event of its choosing. The returned event's ID, computed under the room version, must now equal the requested ID; otherwise it is dropped. Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([event_id_mismatch]). -
FIXED: room version 3 event IDs used the wrong base64 alphabet. rooms/v3.md defines the event ID as the reference hash in standard Unpadded Base64; only v4 moved to URL-safe. Every version used URL-safe, so any v3 event whose hash contained
+or/(most of them) got an ID other servers do not compute.RoomVersionPolicy::event_id_url_safe_base64is now false for v3 only. Test:tests/conformance/test_events.cpp([v3_event_id]). -
CHANGED: room versions 1 and 2 are no longer supported (ADR-0076). They were advertised and creatable, but their event-ID format (
$localpart:domaincarried in the event), the signature they require from the event ID's domain, and v1's state resolution algorithm were never implemented, so every v1/v2 event got a wrong event ID. Both are removed from the room version registry and refused everywhere:createRoomanswers 400M_UNSUPPORTED_ROOM_VERSION, they are no longer inm.room_versionsor the outboundmake_joinverlist, and an invite into one (including a v1 invite for a room whose version is not known locally, which the spec says implies v1 or v2) answers 400M_INCOMPATIBLE_ROOM_VERSIONwithroom_versioninstead of a 500. Tests that counted twelve versions or used v1 as a supported version were corrected with the user's approval; the scenarios that used v1 to exercise the original auth rules now use v3, which shares them. Tests:[v1_v2_unsupported](conformance and unit capabilities). -
FIXED:
send_joinresponse events were not held to the signing-key validity rule (ADR-0075).filter_verified_send_join_eventsverified signatures without checking that the key was valid at each event'sorigin_server_ts. Both it andauthorize_federation_pdunow use one function,check_signing_key_valid_for_event. Test:tests/unit/test_federation_invite_join.cpp([key_validity]). -
FIXED: event signing-key validity checked against the wrong time (MEDIUM, ADR-0075).
authorize_federation_pdurejected a PDU when its sender's key had expired now, in every room version, and backfill skipped the check. The spec measures it against the event itself: from room v5 the key'svalid_until_tsMUST be at least the event'sorigin_server_ts(rooms/v5.md), and versions 1 to 4 MUST ignorevalid_until_ts. Every path (/send, membership and invite endpoints, backfill, and main's re-verification of worker relays) now applies that rule; an event sent after its key expired is refused, and an old event whose key has expired since is accepted. A fetched key is cached for at most 7 days (min(valid_until_ts, fetched_at + 7 days), rooms/v5.md), which also bounds the resolver's stale-key fallback. Test fixtures whose keys expired before their own events were sent were given realistic validity, and one unit scenario that required the current-time rejection was corrected with the user's approval. Tests:tests/conformance/test_pdu_signature_conformance.cpp([key_validity]). Seedocs/threat-model.mdfor the residual (backdated events within a key's capped validity). -
FIXED: predictable media IDs and unauthenticated legacy media endpoints (MEDIUM, security-audit finding M05). Local media IDs were minted from a counter and content-digest prefix, making them enumerable, and the deprecated
/_matrix/media/v3/downloadand/thumbnailendpoints remained reachable for every upload.upload_local_medianow mints 16-byte (128-bit) CSPRNG IDs encoded as 22-character URL-safe base64 strings; each upload gets a distinct ID even when the same blob is deduplicated. New uploads setlegacy_endpoint_visible = false, so the unauthenticated v3 routes return404 M_NOT_FOUNDwhile authenticated/_matrix/client/v1/media/downloadand/thumbnailroutes continue to serve them. Pre-upgrade rows default tolegacy_endpoint_visible = 'true'via migration 016, preserving existing links. Tests:tests/unit/test_media_repository.cpp([m05]),tests/integration/test_media_repository_security.cpp([m05]). See ADR-0068,docs/media-repository.md, anddocs/database-persistence.md. -
FIXED: membership-path receipt checks (ADR-0064 phase B2).
send_join/send_leave/send_knocknow run the same receipt checks as/send: a content-hash mismatch redacts instead of rejecting; the event is authorised against its ownauth_events, the state before it, and current state, with rejection (stored, 403, never applied to state) and soft failure (stored, resolved, never a forward extremity, membership never flipped) handled per spec. Rejections are logged with their reason. The previously failing test fixtures have been rebuilt to send realistic events with properprev_events,auth_events, content hashes, and (for room v12) reference-hash event IDs; the checks themselves were never relaxed. Full suite is green (Ok: 54,Fail: 0,Timeout: 0). See the resolved handover indocs/todos/capability-gaps.mdunder "RESOLVED (0.12.13)". -
FIXED: inbound PDU backfill of missing
prev_eventsandauth_events(ADR-0064 phase C). When an inbound/sendPDU names references this server has not seen,ingest_pdu_eventnow releases the runtime locks and fetches them from the sending server: first a singlePOST /_matrix/federation/v1/get_missing_events/{roomId}call (up to 20 events), then individualGET /_matrix/federation/v1/event/{eventId}calls for any remaining missing references, with a hard cap of 5 outbound calls per PDU. Every returned event is verified independently — content hash (mismatch redacts), Ed25519 signature,auth_eventsselection, and auth against its ownauth_events— before being stored as an outlier with a recorded after-state group. If references remain missing after the capped attempt, the original PDU still returnsmissing_prev_stateand is not applied. Tests:tests/integration/test_pdu_ingestion_backfill_flow.cpp([pdu_ingestion][backfill]). -
FIXED: backfilled missing events now run the step-5 state-before check (HIGH).
verify_and_store_backfilled_eventpreviously verified a fetched event only against its own namedauth_events(step 4) and stored it as an outlier whose after-state included the event. It now also authorises the event against the state before it, matchingingest_pdu_event. Events that pass step 4 but fail step 5 are stored with statusrejected; their after-state group is the state before the event, so later events that reference the rejected event cannot inherit its state. Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([pdu_ingestion][backfill][conformance]). -
FIXED:
/state_idsfallback for backfill gaps (HIGH). When/get_missing_eventsand per-event fetches cannot close a missing-prev_eventgap,backfill_missing_pdu_referencesnow falls back toGET /_matrix/federation/v1/state_ids/{roomId}?event_id=...to obtain the resolved state at the missing event. The returnedpdu_idsandauth_chain_idsare capped, every named event is fetched via/event/{id}and verified independently, and only the verified state events are used as the snapshot. The missing event itself is then fetched and authorised against that snapshot as its state-before, bypassing the usual requirement that its ownprev_eventsalready have recorded state groups. Oversized responses and unverifiable snapshots are rejected, keeping the fail-closed behaviour. Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([pdu_ingestion][backfill][conformance]). -
FIXED:
/state_idsfallback now uses spec-conformant/event_authfor historical state events (HIGH, ADR-0069 option A). Snapshot state events whoseprev_eventshave no recorded state groups are no longer rejected outright. Instead, when the local state-before is unavailable,backfill_state_ids_snapshotfalls back toGET /_matrix/federation/v1/event_auth/{roomId}/{eventId}to fetch the full auth chain, verifies and stores each returned PDU against its ownauth_events, then stores the snapshot event as an outlier (amended by ADR-0070 below: it carries no recorded state). Snapshot materialisation uses a separate 100-call outbound budget and a 1000-event cap so that a large legitimate room does not exhaust the general PDU backfill budget. Fail-closed checks remain: the fallback is only attempted for snapshot state events, only when theirprev_eventstruly lack state groups, and only after the auth-chain PDUs verify. Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([pdu_ingestion][backfill][conformance]). See ADR-0069. ADR-0069 has been corrected: it named deciders and a date (2026-09-22) that were not accurate. It was ratified on 2026-09-27 with the ADR-0070 amendment. ADR-0064's inaccurate "Deciders" line was removed, and its snapshot cap text now matches the code (1000 events, 100-call budget). -
FIXED: an event verified only against its own
auth_eventsno longer lends a state to later events (MEDIUM, ADR-0070). The/event_authpath above gave each such event an after-state of its ownauth_eventsplus itself, so a later PDU naming it as aprev_eventwas authorised against a thin state the origin chose, rather than the room's state before it (spec receipt check 5). Such an event now carries no state group, and a PDU building on it triggers a fresh/state_idsfallback at that event. It is held asmissing_prev_stateif the origin cannot supply that state. A stored event without a state group (an/event_authoutlier, or one older than ADR-0064's state groups) gains one when a later fallback supplies a verified state before it and it passes auth against that state. Snapshot materialisation now skips every already-stored event, so such outliers are not re-fetched on each snapshot. Tests:tests/integration/test_pdu_ingestion_backfill_flow.cpp([event_auth_outlier], and the updated[backfill][conformance]scenario). See ADR-0070. -
TEST: flaky
FD_CLOEXECcheck on accepted sockets fixed. The listener integration test found the server's end of its connection by scanning/proc/self/fdfor a socket whose peer port matched, so under parallel load it could inspect an unrelated socket that shared the port. It now matches the full local and peer address, and the scenario plants such a lookalike (a non-close-on-exec socket on a lower fd) to prove the match holds. Test:tests/integration/test_http_server_listener_flow.cpp("marks accepted client sockets close-on-exec"). -
REFACTOR: no raw pointer in the worker IPC in-flight guard (audit item 10).
InFlightGuardinsrc/homeserver/worker_pool.cppnow holds anipc::IpcChannel&instead of a pointer, per the project's no-raw-pointers rule. The channel was already never null there. No behaviour change. -
FIXED: backfill dropped
/get_missing_eventschildren listed before their parents (LOW, audit item 9). A backfilled event is authorised against the state before it, which needs itsprev_eventsstored first, but the results were handled in the order the origin sent them. They are now handled in ascendingdepthorder (ties keep the response order). Test:tests/integration/test_pdu_ingestion_backfill_flow.cpp([backfill_depth_order]). Seedocs/event-engine.md. -
FIXED: the federation worker could start without its IPC socket (LOW, audit item 8). With main's stdin closed, the IPC socket pair came back as fds 0 and 3, so the child's end already sat on the fixed child fd 3 and the spawn's
adddup2(3, 3)was a same-fddup2, which some libcs treat as a no-op that leavesFD_CLOEXECset. The newmake_worker_ipc_socketpairmoves the child's end past fds 3 to 5 first, asmake_worker_secret_pipealready did for the secret pipes (the two now share one helper), andspawn_and_connectuses it. Test:tests/unit/test_worker_supervisor.cpp([worker_ipc_fd]), which closes stdin in a forked child on the real kernel. Seedocs/hardening.md. -
DOCS: underscores from an old find-and-replace removed from
docs/hardening.md(audit item 11). Headings and prose such as "Cross_platform", "Build_time" and "Out_of_process" now read with hyphens; identifiers in code spans are untouched. The three documents that cite the "Out-of-process federation worker IPC security" heading were updated to match. -
REMOVED: dead authorization code (audit item 7).
events::authorize_eventandevents::membership_policy_allowshad no production callers and did not follow the spec for bans and knocks. They were deleted with the helpers only they used (auth_rule_hook_name,power_level_allows) and the three conformance scenarios intests/conformance/test_event_authorization.cppthat exercised only them. The auth rules the server enforces areauthorize_event_against_auth_events. The remaining dead helpers,select_auth_eventsandAuthChain, were removed later on this branch (see below). -
FIXED: refresh-token rotation broke lost-response retries and never detected reuse (MEDIUM, audit item 6, ADR-0074, migration 017). Per the spec, the presented refresh token now stays valid until the new access or refresh token is first used, so a client whose
/refreshresponse was lost can retry (before, it was logged out). Presenting a refresh token that has been invalidated revokes the device's whole session and is audited asauth.refresh.reuse_detected. Migration 017 addsrefresh_tokens.predecessor_hashandaccess_tokens.predecessor_refresh_hash. One unit scenario that required single-use refresh tokens was corrected with the spec citation, with the user's approval. Tests:[refresh_rotation](conformance and integration),tests/unit/test_homeserver_auth_service.cpp. -
FIXED: access tokens never expired after a restart (HIGH, found while doing item 6). Hydration rebuilt each in-memory session without its
expires_at, and authentication reads only the session. Test:tests/integration/test_persistent_homeserver_flow.cpp([session_restart]). -
FIXED: the user directory returned deactivated accounts and ignored
limit(LOW, audit item 5).POST /user_directory/searchnow skips deactivated local accounts, returns at mostlimitresults (spec default 10) and setslimitedwhen it truncated the list; it always saidfalsebefore. Test:tests/conformance/test_client_server_conformance.cpp([user_directory]). -
FIXED: IPv6 clients could escape rate limits within one /64, and a client could pick its own bucket through X-Forwarded-For (LOW and MEDIUM, audit item 4, ADR-0073). Rate-limit buckets now group IPv6 clients by
server.http.ipv6_client_prefix_length(default /64; IPv4 unchanged), for both the client-server rate limiter and the federation key-resolution budget. Behind a trusted proxy the client is now the rightmostX-Forwarded-Forentry that is not itself a trusted proxy, with every header line read as one list; before, the leftmost entry was used, which the client controls when the proxy appends to the header. Tests:tests/unit/test_client_server.cpp([rate_limit_keys]). -
FIXED: redaction kept content keys the room version's algorithm drops (LOW, audit item 3). The redacted form feeds the reference hash, so each of these gave affected events a different event ID from other servers:
m.room.aliaseskeptaliasesthrough v10 (rooms/v6.md removed it; now v1–v5 only);m.room.memberkeptjoin_authorised_via_users_serverin every version (now v9+; v8 keeps onlymembership); andm.room.third_party_invitekeptsigned(no version lists that event type; now stripped to{}). Two existing conformance scenarios intests/conformance/test_redaction_conformance.cpprequired the old behaviour and were corrected with spec citations, with the user's approval. Event IDs already stored are not recomputed. Test:[redaction_versions](15 version/type combinations). -
FIXED: join rules not gated by room version;
knock_restrictedjoins refused (LOW, audit item 2). The join path accepted onlyrestrictedand the non-specrestricted_v2, so a legitimateknock_restrictedjoin in a v10+ room was refused (rooms/v10.md rule 4.3.5: "If the join_rule is restricted or knock_restricted"). Each join rule is now recognised only in the versions that define it:knockfrom v7,restrictedfrom v8,knock_restrictedfrom v10, for both joins and knocks.restricted_v2and any rule the version does not define are rejected, and aknockmembership is rejected before v7. NewRoomVersionPolicyflags carry the table. Test:tests/conformance/test_event_auth_rules.cpp([join_rule_versions], 17 version/rule combinations). -
FIXED: no per-IP connection cap (MEDIUM, audit item 1, ADR-0072). One host could fill the global connection budget with connections held just under the slow-request thresholds. Both accept loops now refuse a connection, before reading a byte or starting a TLS handshake, once its client holds
server.http.max_connections_per_ip(new, default 64) connections. IPv6 clients are grouped byserver.http.ipv6_client_prefix_length(new, default 64). Addresses inserver.trusted_proxiesare exempt. The slot is RAII and travels with the connection through every hand-off.--plan-config-reloadnow reports everyserver.http.*change (it reported none before), all restart-required. Tests:tests/unit/test_http_connection_limiter.cpp,tests/unit/test_config_parser.cpp,tests/unit/test_config_reload_plan.cpp,tests/integration/test_http_server_listener_flow.cpp([connection_limit]). -
FIXED: main trusted the federation worker's signature check on relayed PDUs (MEDIUM, ADR-0071, threat-model #450). A compromised worker could make main persist events impersonating any sender the room's state authorised.
pdu_ingest,membership_ingestandinvite_ingestnow verify the sender server's signature with main's ownremote_key_resolver, before the runtime lock is taken, and/sendand membership envelopes are rebuilt from the verified event instead of the worker's framed fields; a frame whose event ID disagrees with the signed event is refused. The worker-flow fixtures that relayed unsigned events under a placeholder event ID now sign as the sender's server. Tests:tests/integration/test_worker_relay_signature_flow.cpp([worker_relay_signature]),tests/integration/test_federation_worker_flow.cpp. -
FIXED: data race on the server signing-key cache (HIGH, found while doing the item above).
PersistentStore::server_signing_keyswas an unguarded vector written by the remote-key resolver from the inbound path's parallel key fan-out and from backfill, both without the runtime mutex, while other threads read it. A concurrent store/lookup test crashed with SIGSEGV in 4 of 4 runs. The vector now has its own mutex, and readers that iterate usesnapshot_server_signing_keys. Test:tests/unit/test_database_persistence.cpp([signing-key][concurrency], also clean under ThreadSanitizer). -
FIXED: push gateway and identity server clients skipped SSRF filtering on address resolution (MEDIUM).
push_gateway_client.cppandidentity_client.cppwere callingCachedServerDiscovery::upstream().lookup_addresses(...)directly, which bypassed the private/loopback rejection applied bydiscover_server/resolve_federation_destination. Both clients now call the newCachedServerDiscovery::lookup_addresses_filtered, which rejects private, loopback, link-local, CGNAT, NAT64, multicast, and reserved addresses before any outbound connection is attempted. The IPv4/IPv6 classifiers insrc/federation/security.cppwere expanded to cover CGNAT (100.64/10), multicast (224/4), reserved (240/4), NAT64 (64:ff9b::/96), and IPv4-mapped variants;address_set_allowedis now a public helper used by both federation discovery and the filtered lookup. The Application Service client intentionally continues to use the raw resolver: appservice URLs are operator-configured, not attacker-influenced, and the spec's own example uses cleartext HTTP. Tests:tests/unit/test_federation_security.cpp([federation][security][ssrf]),tests/unit/test_push_gateway_client.cpp([push][push-gateway][ssrf]). Updatedsrc/push/AGENTS.mdandsrc/identity/AGENTS.md. -
FIXED: password re-authentication for UI-auth flows bypassed the per-account failed-login lockout (MEDIUM).
verify_local_user_passwordwas only checking the password hash, so an attacker with a stolen access token could guess the password through/account/password,/account/deactivate,/account/3pid/add,/keys/device_signing/upload, and the single/bulk/devices/{deviceId}/deletepaths without ever sharing the/loginfailure budget.verify_local_user_passwordnow returnsPasswordVerificationResult(okandretry_after_ms) and consults the same per-user failed-login counter as/login: it refuses to check the password while the account is locked out, records each wrong attempt against the authenticated user, and clears the history on a correct password. All six UIA call sites translate a non-zeroretry_after_msinto429 M_LIMIT_EXCEEDEDwith aRetry-Afterheader. Test:tests/unit/test_homeserver_auth_service.cpp([m02]). See ADR-0066 and updateddocs/auth-identity.md. -
FIXED: no size or field-count limits on federation PDUs (MEDIUM).
parse_inbound_pdu_envelopenow rejects any inbound PDU whose raw canonical JSON exceeds 65 536 bytes, whoseprev_eventsarray exceeds 20 entries, or whoseauth_eventsarray exceeds 10 entries — all before hashing or signature verification.parse_event_envelopeandmatrix_id_is_validenforce the 255-byte caps onstate_key,sender,room_id, andtype. The shared constants live ininclude/merovingian/events/limits.hpp. Tests:tests/unit/test_inbound_ingestion.cpp([m03]) andtests/unit/test_event.cpp([m03]). See ADR-0067 anddocs/event-engine.md. -
FIXED:
m.federate: falsewas not enforced for room versions 1–5 (MEDIUM). Thecontent.m.federatesender-domain check in authorization rule step 3 was gated onroom_v6_plus || room_v12, so v1–v5 rooms that disabled federation still accepted events from any domain. The check now applies to every room version: whenm.federateisfalse, the sender's domain must match the create event's creator domain (or, for v11+/v12, the create event's sender).m.federateabsent ortruestill permits cross-domain senders in all versions. Tests:tests/conformance/test_event_auth_rules.cpp([m04]). Seedocs/event-engine.md.
Fixes every Critical and High finding from the September 2026 full-project bug audit.
-
Master key file now gets the same startup metadata check as every other secret file (HIGH).
security.secrets.master_key_file— the root secret from which the signing-secret box key, access-token HMAC keys, and federation-worker IPC auth key are derived — was opened with a plainstd::ifstreamwithout validating owner-only, non-executable, regular-file permissions. A group/world-readable master key or a symlink swapped in at startup was silently accepted.validate_existing_secret_filesinsrc/main.cppnow applies the samelstat-based, TOCTOU-safe check used fordatabase.uri_file,security.registration.token_file, and listener TLS private keys. Operators upgrading from an earlier release may need a one-timechmod 0400on the master key. Tests:tests/integration/test_secret_file_validation_flow.cpp([secret_files]). -
State resolution v2 could diverge from conformant servers by ignoring the auth difference (HIGH, consensus-critical).
resolve_state_v2only ever considered power events literally present in the two conflicted state groups it was handed, never the spec's full conflicted set (conflicted state set + auth difference, rooms/v10.md — Definitions). A power-level change reachable only through a conflicted event'sauth_eventschain — for example a promotion that authorised a later ban — was invisible to the resolver, so its effect was silently dropped and resolved state could differ from another server that correctly walked the chain. Room v12 (state-res v2.1, rooms/v12.md) additionally needed the conflicted state subgraph and an empty starting map for the iterative auth checks; neither was implemented, so v12 rooms used the wrong algorithm entirely (StateResolutionAlgorithmgainedv2_1, distinct fromv2).StateResolutionRequestnow carries anevent_lookupcallback (include/merovingian/events/state_resolution.hpp) so the resolver can walkauth_eventschains beyond the submitted state groups; production wires this to the persistent store insrc/homeserver/local_http_router.cpp'sstate_conflict_resolver. The walk is bounded (events::max_auth_chain_walk_events,include/merovingian/events/limits.hpp, 20 000 events) and fails closed: a missing/unreachable event or an over-budget walk resolves torejected_state_conflictrather than resolving on a partial chain (see ADR-0063). The iterative auth checks' ownauth_eventsfallback (used when a required key is absent from the running state — spec: "Iterative auth checks") was also missing entirely and is now implemented inbuild_auth_event_map_from_state; without it, v12's empty starting map left almost every candidate event unable to find its own auth context. Tests:tests/unit/test_state_resolution_auth_diff.cpp([state_res_v2]— auth difference, fail-closed missing/cycle/cap, determinism, v12 empty-start, v12 conflicted state subgraph) and four new scenarios intests/conformance/test_state_resolution_conformance.cpp(ban vs. concurrent power-level demotion, a kick surviving an unrelated topic conflict, join-rule evasion, and the same-power/same-timestamp event_id tie-break). -
Reverse topological power ordering read a candidate's sender power from the wrong source (HIGH, consensus-critical — found in review of the fix above).
power_level_from_eventread anm.room.power_levelscandidate's sender power from that SAME event's own new content — a self-elevating power_levels event (one that grants its own sender a level it does not actually hold) ranked itself by the level it claims, not the level its ownauth_eventsancestor actually grants it — and read every other candidate's power from a shared "unconflicted" state map, which is simply the wrong source per spec (rooms/v10.md — Reverse topological power ordering, rule 1: power is read "looking at their respective auth_events"). Fix:find_auth_ancestor_contextwalks the candidate's ownauth_eventsfor itsm.room.power_levels(and, for v12,m.room.create) ancestor via the same fail-closedAuthChainEventSourceused for the auth difference, and feeds the result toevents::effective_sender_power— moved out ofauthorization.cpp's anonymous namespace and declared inauthorization.hppso the ordering and the auth rules read power identically, including MSC4289 creator-infinite power for room v12.reverse_topological_power_sort's signature changed:EventJsonIndex+EventLookupFninstead ofStateMap unconflicted, and returnsoptional<vector<StateEventReference>>—nulloptfails the sort closed (ADR-0063) when anauth_eventsentry needed to answer the question cannot be resolved, rather than guessing a default from a partial chain.mainline_orderwas audited against the same defect class and found not to have it — it already reads each event's ownauth_eventspower-levels ancestor and never computes a power level at all, only a mainline position. Tests ([state_res_v2], tag[power-ordering], intests/unit/test_state_resolution_auth_diff.cppandtests/conformance/test_state_resolution_conformance.cpp): a self-elevating power_levels candidate loses to a genuinely higher-powered sender; a non-power candidate reads its ownauth_eventsancestor instead of a shared unconflicted power_levels event; a v12 room creator outranks any finite power_levels level; an unresolvableauth_eventsancestor fails the sort closed; string-vs-integer power-level encoding (v1-9 vs v10+) is now exercised on theauth_eventsancestor rather than a candidate's own content. -
Thumbnail decoder worker hardening is now fail-closed.
harden()insrc/media/thumbnail_worker_main.cppdiscarded the result of every hardening call (setrlimitx5,prctl(PR_SET_DUMPABLE),prctl(PR_SET_NO_NEW_PRIVS), the decoder seccomp filter,pledge,cap_enter) withstd::ignore, then unconditionally read stdin and ran libpng/libjpeg-turbo on attacker-controlled bytes — a sandbox that failed to install left the decoder running fully unconfined, silently. Hardening is nowmedia::apply_decoder_hardening()(include/merovingian/media/decoder_hardening.hpp,src/media/decoder_hardening.cpp): every applicable control must succeed or the worker writes the failed control's name to stderr and exits1before reading any input. The real syscalls sit behind an injectableDecoderHardeningOpsfunction table so the fail-closed sequencing is unit tested per control (tests/unit/test_media_decoder_hardening.cpp,[thumbnail_hardening]) without installing a broken sandbox in the test process. The parent (src/media/thumbnailer.cpp) already turns a worker that exits before producing output into a normal failed-thumbnail response (502), so no parent-side change was needed. -
Federation worker no longer opens the operator master-key file (finding N1, part 1 of 3). The worker derived its own copy of the IPC channel's auth key from the master-key file at every startup and restart, even though no worker-reachable code path ever needed the other keys derivable from that file (the signing-secret box key, the access-token HMAC keys) — a compromised worker could still read the file and derive them anyway. Main now derives the IPC auth key once (
WorkerPool's constructor) and hands it to the worker over a second inherited pipe fd (--ipc-key-fd) at spawn time;make_worker_key_pipewrites the key and closes the write end, and the read end staysFD_CLOEXECin main — aposix_spawn_file_actions_adddup2places it at the fixed fd 4 in the child only, so no concurrent spawn in the multithreaded main process (another shard's restart, the thumbnail decoder) can inherit the pipe carrying the key. The worker (federation_worker::read_ipc_auth_key,src/federation_worker/ipc_key_fd.cpp) reads exactly the expected number of bytes, requires EOF immediately after, and fails closed (LOG_CRITICAL, non-zero exit) on a short, long, or missing key;federation_worker::clear_master_key_filethen emptiessecurity.secrets.master_key_fileon the worker's own config copy beforehomeserver::start_runtime()runs, so no worker code path can open that file even by accident.args.cpprequires the new--ipc-key-fdand validates it is distinct from--ipc-fdand not 0/1/2. See ADR-0062 for the full three-part design; parts 2 (a separate, least-privilege worker database login) and 3 (Linux Landlock filesystem restriction) are tracked follow-ups, not yet shipped. -
Federation worker no longer shares main's PostgreSQL login, and never hydrates credential-bearing tables into its own memory (finding N1, part 2 of 3). Before this, a PostgreSQL-backed worker connected with the exact same login role as main, so a compromise could
SELECT server_signing_keys.secret_key(still ciphertext, but exactly what part 1's fix stops the worker from independently decrypting) and every other credential-bearing table, even though no worker code path reads them. Two independent mechanisms close this: - A separate, least-privilege login, delivered the same inherited-fd
way as part 1's IPC auth key. New config keys
federation.worker.database_uri_file(a secret file holding the role's connection URI, validated the same way asdatabase.uri_file) andfederation.worker.allow_shared_database_credentials(an explicit,CRITICAL-logged opt-out).config::validate()rejectsdatabase.backend=postgresqlwith federation enabled and an emptydatabase_uri_fileunless the opt-out is set.homeserver::make_worker_key_pipe's pipe construction is generalised intomake_worker_secret_pipe(secret, reserved_fds), reused by the newmake_worker_db_uri_pipe(reserves fds 3, 4, and the newkWorkerDbUriFd5);WorkerPool::WorkerPoolderives the URI once and eachWorkerSupervisor::spawn_and_connectpasses--db-uri-fd <n>and a thirdposix_spawn_file_actions_adddup2, only when a separate URI actually applies. The worker (federation_worker::read_worker_database_uri,src/federation_worker/db_uri_fd.cpp) reads the URI until EOF (bounded to 4096 bytes, unlike the fixed-length auth key), andfederation_worker::apply_worker_database_urisets the delivered URI as the worker's own connection override while clearing its config copy'sdatabase.uri_file/runtime_role/migration_role, so the restricted login connects directly and never attempts aSET ROLEonto roles it was never granted.packaging/postgresql/ provision-federation-worker-role.sqlprovisions the role as an explicit per-table allowlist:SELECT(neverINSERT/UPDATE/DELETE— the worker's own store is a read-only snapshot; every write is relayed to main over IPC) granted table by table on exactly the nine tablesdatabase::federation_worker_table_allowlistnames, with noALTER DEFAULT PRIVILEGESand noGRANT ... ON ALL TABLES— a table a future migration adds is unreadable by this role until someone grants it deliberately. -
A load profile, independent of which credential the worker holds.
database::TableLoadProfileanddatabase::federation_worker_table_allowlist(include/merovingian/database/persistent_store.hpp) decide which tablesopen_postgresql_persistent_store's row loader hydrates.TableLoadProfile::federation_workeris likewise an allowlist of nine tables —rooms,membership,current_state,events,event_edges,event_auth,event_signatures,room_aliases, and (column-restricted, neversecret_key)server_signing_keys— derived by tracing everyFederationRuntimeStatecallback the worker does not override to the table it reads; a table absent from the array is never hydrated.RuntimeStartOptions::database_load_profileis set tofederation_workerunconditionally byWorkerEventLoop::run(), so even a worker running in theallow_shared_database_credentials=truedegraded mode never pulls an unlisted table into its own process memory.server_signing_keysneeded a column-level answer: the worker's remote-key cache legitimately reads/writes other servers' rows in this table, soload_persistent_rowsuses a worker-specific 4-column query that never selectssecret_key, and the SQL grant is itself column-restricted to match. A unit test (tests/unit/test_worker_db_uri.cpp, tag[worker_db_uri]) parses both the SQL grant list and the C++ allowlist from the source tree and asserts they name the same table set, and separately asserts every tablemigrations/*.sqlcreates is classified as allowlisted, never-read, or (forschema_migrations, needed to open any store) ungated. SQLite is unaffected by either mechanism — a single shared file offers no role to separate; part 1's file-access removal remains that backend's protection for the master key specifically, and the load profile is a defense-in-depth reduction there, not a boundary. See ADR-0062 anddocs/database-persistence.md, "Federation worker least-privilege role", for the full design and the GRANT/REVOKE SQL. -
Federation worker now restricts its own filesystem access with Linux Landlock (finding N1, part 3 of 3). Parts 1 and 2 stopped the worker opening the master-key file and main's database credentials through its own intentional code paths, but the worker still ran as an ordinary process with no filesystem sandbox — a compromise reached through a memory-safety bug, rather than one abusing an intentionally-opened file, could still open the master key, either database URI file, or TLS private keys directly off disk.
platform::apply_worker_landlock()(include/merovingian/platform/landlock_hardening.hpp,src/platform/landlock_hardening.cpp) is now called fromfederation_worker::main()before the worker seccomp filter is installed (Landlock's three syscalls are not on that filter's allowlist, and applying Landlock first avoids adding them there for a one-time startup step) and before the event loop opens the database or handles any inbound request. It queries the running kernel's Landlock ABI version, builds alandlock_create_rulesethandled-access mask downgraded to whatever that ABI supports, adds onelandlock_add_ruleper pathplatform::build_worker_landlock_rules()derives from the worker's own config copy (the SQLite database directory, read-write, whendatabase.backend=sqlite) plus a fixed set of best-effort, read-only or read-execute OS-integration paths (CA trust store candidates, resolver configuration, NSS/dynamic-linker library directories, timezone data), then callslandlock_restrict_self. The master key file, both database URI files, and the TLS private key paths are never in the rule set, and the worker refuses to start if any rule equals or is an ancestor directory of one of them (platform::find_landlock_rule_covering_secret, compared by path component afterweakly_canonical) — a rule on a directory grants everything beneath it, so a master key kept beside the SQLite database would otherwise be exposed read-write. The CA grants cover only the certificate stores, never all of/etc/sslor/etc/pki, which hold the conventional private-key directories. Three kernel-contract bugs found in review were fixed before this shipped: file rules requested directory-only rights (EINVALon/etc/resolv.conf);landlock_restrict_selfran beforePR_SET_NO_NEW_PRIVSwas set (EPERM; now set immediately before it, failure fatal); and the worker, which inherits the main server's seccomp filter acrossexecve, was killed by it because main's allowlist lacked the Landlock syscalls (now allowed there — Landlock can only narrow access). The forked real-kernel test now skips only when the kernel reports no Landlock ABI; it had been reporting theEPERMas a skip. When the running kernel has no Landlock support (older than Linux 5.13, or Landlock disabled at boot), the worker refuses to start unless the newfederation.worker.allow_without_landlock=trueopt-out is set, in which case it logsCRITICALon every start — mirroring the fail-closed seccomp policy in ADR-0041. Any other Landlock failure (ruleset creation, a required rule, orlandlock_restrict_selfitself) is always fatal, regardless of the opt-out — the opt-out covers "this kernel has no Landlock", never "Landlock is present but broken". The real kernel calls sit behind an injectableLandlockHardeningOpsfunction table (mirroringmedia::DecoderHardeningOps) so every fail-closed path is unit tested (tests/unit/test_worker_landlock.cpp, tag[worker_landlock]) without restricting the test process itself; a forked real-kernel scenario in the same file additionally proves a denied path is refused withEACCESand an allowed path still opens, skipping cleanly on a kernel without Landlock. See ADR-0062, part 3, for the full allowlist derivation and itsstraceevidence. -
Main now caps in-flight federation-worker IPC requests per channel (HIGH). ADR-0027 left the IPC dispatch pools unbounded on the premise that their producer was the trusted local supervisor. After finding N1, the worker is the least-trusted process: a compromise there could flood main with queued IPC handlers until the OOM reaper killed client traffic too.
federation.worker.ipc_max_in_flight_requests(default 256, restart-required) configures a per-channel semaphore on each worker's IPC channel.WorkerPoolacquires a slot before queuing a handler and releases it when the handler finishes; requests over the cap receive an explicit error reply immediately instead of being queued.pdu_ingestreplies with{"status":"main_overloaded"};membership_ingestandinvite_ingestreply with{"accepted":false,"status":503}; the worker maps these to a retryable HTTP 503 toward the remote so the transaction is retried rather than dropped. See ADR-0065, which supersedes the IPC-pool half of ADR-0027. -
Phase A of spec-conformant PDU ingestion: delta state group storage (ADR-0064). State resolution v2 was fixed in this same release, but nothing in the ingest path actually populated
PduIngestionResult:: state_conflict, so it never ran —ingest_pdu_eventauthorised each inbound PDU against current state only and wrote state events straight intocurrent_state, letting two concurrent valid state events resolve as "whichever arrived last" instead of deterministically. This is the storage layer the later ingestion phases need to fix that; it does not itself change ingestion behaviour, localprev_eventsselection, or what clients see. Migration015_event_graph_state.sqladdsevents.status('accepted' | 'soft_failed' | 'rejected' | 'outlier', existing rows default to'accepted'); extends the pre-existing but vestigialstate_groupstable withparent_state_group_id/delta_depthso it can anchor a delta chain (a group is a full snapshot when the parent is empty, or a delta from its parent otherwise, capped atevents::max_state_group_delta_depth= 100 hops before a fresh snapshot is forced); and addsstate_group_state,event_state_groups, andforward_extremities. Every pre-existing room is seeded with one snapshot state group built from itscurrent_state, attached to its current forward extremities — a seeding step that, along with phase B's future per-event children lookup, filtersevent_edgesbyprev_event_idalone, outside the leading column of its(event_id, prev_event_id)primary key; migration 015 therefore also createsevent_edges_prev_event_id ON event_edges (prev_event_id)(the project's first index, positioned before the seed statements so seeding itself uses it), fixing what was previously a full table scan per probe during seeding.state_groupsgets no matchingroom_idindex: no store function queries it byroom_id, only by itsstate_group_idprimary key. New store API ininclude/merovingian/database/persistent_store.hpp:create_or_reuse_state_group,read_state_group_full_state(bounded, cycle-detected, fails closed rather than ever returning partial state),find_state_group,update_forward_extremities/find_forward_extremities, andset_event_status/find_event_status; implemented for SQLite, PostgreSQL, and the in-memory store, and added to the federation worker's least-privilege PostgreSQL role and table allowlist (packaging/postgresql/provision-federation-worker-role.sql,database::federation_worker_table_allowlist) since the worker already serves federation/state//state_idslocally and a later phase moves that onto state groups.state_group_edgesremains vestigial and untouched. See ADR-0064,docs/database-persistence.md,tests/unit/test_state_groups.cpp([state_groups]), andtests/integration/test_state_groups_flow.cpp. -
Phase B1 of spec-conformant PDU ingestion: state resolution wired into
ingest_pdu_event(ADR-0064, HIGH — this is the delivery-order divergence ADR-0064 exists to fix). Phase A above added the storage; nothing called it.ingest_pdu_eventstill authorised each inbound PDU against current state only, then wrote it straight intocurrent_state— two concurrent, individually valid state events resolved as whichever committed last, not by spec resolution, so resolved state could diverge across the federation depending on delivery order. New modulemerovingian::homeserver::state_bookkeeping(include/merovingian/homeserver/state_bookkeeping.hpp,src/homeserver/state_bookkeeping.cpp):compute_state_before(the state before an event — a singleprev_event's own after-state directly, several resolved viaresolve_state_v2, failing closed to a newPduIngestionStatus::missing_prev_state— not a rejection; the transaction still returns 200 per spec — when aprev_eventhas no recorded state group),compute_state_after(state-before plus the event itself when it is a state event),record_event_state(the after-state group and forward-extremity bookkeeping for every accepted event), andrecompute_current_state(current state as the resolution over the room's forward extremities, diffed against the cache and written only where changed through the existingdatabase::store_state, sostate_transitions/unsigned.replaces_stateand the sync wake-up path need no new plumbing). Threadedmissing_prev_statethrough the federation-worker IPC status mapping (src/homeserver/worker_pool.cpp/src/federation_worker/worker_event_loop.cpp) and the/sendtransaction per-PDU switch (src/federation/inbound_request.cpp) so a worker-relayed PDU reports it correctly and a transaction containing it still succeeds. Tests:tests/unit/test_state_bookkeeping.cppandtests/unit/test_pdu_ingestion_state_groups.cpp([pdu_ingestion] [state_groups]) — delivery-order independence (the core regression), a merge event's state-before as the resolution of both fork tips, per-event state-group correctness, forward-extremity fork/merge, and the missing-state fail-closed path, both directly against the module and end-to-end throughingest_pdu_event. At this point local event-creation paths still choseprev_eventsthe old way and the join flow seeded no state group — closed by the next entry, same release. -
Phase B1 completion: every local and inbound event path now participates in state bookkeeping (ADR-0064, HIGH — the phase B1 entry above closed the regression only for federation's
/sendtransaction path; every OTHER path that stores an event was still the pre-ADR-0064 code, which is itself a regression against main: a locally sent message got no after-state group or forward-extremity update, so the very next inbound PDU naming it as aprev_eventfailed closed withmissing_prev_stateand was dropped, and a room joined after upgrading lost every inbound event the same way, because the join event had no state group either). - New choke point
homeserver::store_local_event(state_bookkeeping.hpp/.cpp): computes state-before fromevent.prev_event_ids, stores the event, records its after-state group and forward-extremity update, and recomputescurrent_state— the same sequenceingest_pdu_eventruns, except every failure here is a HARD failure (the whole request fails) rather than logged-and-ignored, since a local event has not yet been told "success" to anyone. Newhomeserver::forward_extremities_for_new_eventreplacesprevious_events_for_room's old body ("the last event pushed intostore.eventsfor this room" — no relationship to the DAG once federation can fork a room's tip) with the room's real forward extremities, capped at the spec's 20-prev_eventslimit (events::max_prev_events_per_event) by highest depth. Newhomeserver::record_event_state_with_parentisrecord_event_stategeneralised to an explicit parent group, for the join-seeding path below. persist_composed_event(used bycreate_room's initial-state chain, invite/join composition, and every ordinary send/state-send/redaction viasend_event, which previously duplicated the raw store call instead of reusing this function) now callsstore_local_eventinstead ofdatabase::store_event_with_statedirectly.ComposedEventgained aroom_versionfield so the choke point does not need to re-derive it (impossible for a room's own create event:store.statehas no create row yet while composing it).local_http_router.cpp'smembership_acceptor(a remote user'ssend_join/send_leave/send_knockacceptance into a room WE are resident in — the same untrusted-input trust boundary asingest_pdu_event, just a different endpoint) gets the identical state-before check, after-state recording, andcurrent_staterecompute. Fixed a separate latent bug found while wiring this: the callback never setevent.prev_event_idsfrom the envelope at all, so every accepted membership PDU was stored as if it had no prev_events.invite_handler's invite-event store (kept only for a futuresend_join's auth-chain walk, never part of this server's own timeline) now gets explicitstatus = "outlier"instead of silently defaulting to"accepted".- Federated-join seeding (
join_room/perform_federated_join,room_service.cpp):ingest_send_join_statenow stores the response's critical state events withstatus = "outlier"and returns the full state map alongside the joined-members list (SendJoinStateIngestResult); the auth-chain persist loop gets the same outlier status. After the join event is stored, a snapshot state group is built from the returned state (database::create_or_reuse_state_group, no parent — a full snapshot, since we have no prior state-group history for this room), the join event gets an after-state group chained off that snapshot viarecord_event_state_with_parent, andrecompute_current_stateruns — so the join event becomes the room's sole forward extremity with a correct after-state, and the first inbound PDU after the join resolves normally instead of hittingmissing_prev_state. - Source-tree guard test
(
tests/unit/test_store_event_choke_point.cpp): scans every.cppundersrc/(excludingsrc/database/, the store abstraction itself) and fails ifdatabase::store_event_with_state(appears anywhere outside a reviewed, counted allowlist — the choke point itself, and the handful of deliberate outlier/bespoke-bookkeeping exceptions listed above. A future path bypassing bookkeeping the waysend_eventandmembership_acceptordid cannot land unnoticed again. - Bug found by the new tests and fixed:
database::store_state'sINSERT INTO state_transitionshad noON CONFLICThandling, but the table's primary key is(room_id, event_type, state_key, event_id)— notprevious_event_id. State resolution making an event current again after it was already superseded once (exactly the "reverts to an older event" case the spec requires be delivered like any other state change) re-inserts that same primary key, which both SQLite and PostgreSQL reject as a duplicate row —commit_persistent_transactionsilently failed andrecompute_current_state's resolved winner was never written,current_statestaying on whichever value the old direct-write path left there. Caught by delivery-order-independence tests where the two orderings diverged. Fixed: the write is now an upsert (ON CONFLICT (room_id, event_type, state_key, event_id) DO UPDATE SET previous_event_id = excluded.previous_event_id), matching the idiom already used elsewhere in the file, with the in-memorystate_transition_indexmirror updated to match (update in place on a reactivation rather than leaving the index pointing at a stale entry). -
Tests:
tests/unit/test_local_event_state_bookkeeping.cpp(the regression: a local send then a remote PDU referencing it must be accepted, notmissing_prev_state; a local send after an inbound fork lists both tips asprev_eventswith a correctly resolved state-before; every room-creation initial event has a correct after-state group and the room's sole extremity is the last one),tests/integration/test_join_room_flow.cpp(a federated join's join event gets the returned state plus itself as its after-state, becomes the sole forward extremity, and a subsequent inbound PDU referencing it is accepted), andtests/unit/test_sync_handler.cpp(a fork whose resolution reactivates an already-superseded event is delivered to an incremental/syncas an ordinary state change — the scenario thestate_transitionsupsert fix exists for). All tags[pdu_ingestion][state_groups](plus[sync][handler]for the last). -
Phase B2 of spec-conformant PDU ingestion: the receipt-order checks themselves (ADR-0064, HIGH). Phase B1 made the state model correct;
ingest_pdu_eventstill authorised every inbound PDU against current state only, treated any auth failure as a hard rejection, and rejected (instead of redacting) a content-hash mismatch.ingest_pdu_event(src/homeserver/local_http_router.cpp) now runs the spec's checks in order (server-server-api.md, "Checks performed on receipt of a PDU"): - Step 3 (hash). A mismatch no longer rejects the event —
events::redact_eventis applied and processing continues with the redacted form, which is what gets stored (contentand the room version's other non-essential keys are stripped;event_id,sender, and the redaction survivor set are unaffected). - Step 4 (auth against the PDU's own
auth_events). Newvalidate_auth_events_selectionenforces the spec's "Auth events selection" list (create, current power_levels, sender's own member event, and — form.room.member— the target member, join_rules, third_party_invite, and restricted-join authorising member, each conditioned correctly on the requested membership): a namedauth_eventsentry of a disallowed type, a duplicate(type, state_key), or one from a different room is a rejection; one this store cannot resolve at all ismissing_prev_state(awaiting backfill), not a rejection.build_pdu_auth_event_mapis generalised tobuild_auth_event_map_from_entries(an arbitrary flat state snapshot, not juststore.state) so the same code builds the auth map from the namedauth_events, the state before the event, and current state. - Step 5 (auth against the state before the event), using phase B1's
compute_state_before. - Step 6 (auth against current state) — failure here is a soft failure, not a rejection, per spec.
- Rejection and soft failure both now store the event (previously a
step-4/5 failure was a hard rejection that never persisted it at all,
breaking the spec requirement that later events referencing a rejected
event can still be authorised).
record_event_state/record_event_state_with_parent(state_bookkeeping.hpp/.cpp) gained anacceptedflag (defaulttrue):falsestill creates/reuses the event's after-state group and maps the event to it, but skips the forward-extremity update (spec: neither a rejected nor a soft-failed event is ever a forward extremity). A rejected event's after-state isstate_beforeunchanged (spec: "not updating with the rejected event"); a soft-failed event's after-state is the normally-computedcompute_state_afterresult (spec: "participate in state resolution as normal"), so a later accepted event chaining off a soft-failed state event's group correctly admits it intocurrent_state— and, once admitted, it is served to clients through the ordinarystatesection, same as any other current-state entry.PersistentEvent::status(the"accepted"|"rejected"|"soft_failed"|"outlier"column phase A added but nothing ever set to the first two — see the security audit finding in the 0.12.12 entries below) is now set accordingly at ingest time; naive immediatestore.state/membership-cache writes only ever happen for an accepted event. - New
PduIngestionStatus::soft_failed, threaded through the worker IPC status mapping (src/homeserver/worker_pool.cpp,src/federation_worker/worker_event_loop.cpp) and the/sendtransaction per-PDU switch (src/federation/inbound_request.cpp): a transaction containing a mix of accepted, rejected, and soft-failed PDUs still returns 200 with per-PDU accounting (spec: a rejected event in a transaction "should not cause the transaction request to be responded to with an error response"). - Client-delivery filtering. Every path that serves room timeline
events now excludes
rejected/soft_failedevents, while state delivery (driven bycurrent_state, untouched by this filter) still shows a soft-failed state event resolution has admitted:/synctimeline (client_server.cpp), MSC4186 sliding sync timeline (sync/sliding_sync_room_builder.cpp),GET .../messages(messages_json),GET .../context/{eventId}(room_context_jsonand its caller's target-event 404 gate),GET .../event/{eventId}(same 404 gate), and search (search_room_events_json's candidate scan andbuild_search_event_context). Federation-facing reads (/event/<id>,/backfill,/get_missing_events,src/federation/event_query.cpp) are intentionally unchanged: the spec allows/event/<id>to return a soft-failed event and only excludes soft-failed events from/backfill//get_missing_eventswhen the request does not itself reference them, which those endpoints' existing depth/reference-driven scans already satisfy. Push notification delivery was already correctly gated (only anacceptedPduIngestionResultreachesdeliver_federation_push_notifications). - Deleted the dead state-conflict-resolver plumbing ADR-0064
identified as unreachable (nothing ever set
PduIngestionResult::state_conflict, sostate_conflict_resolverwas never called):PduStateConflictContext,PduIngestionResult::state_conflict,StateConflictResolver,ResolvedStateApplier,runtime.federation.state_conflict_resolver, the resolver lambda inlocal_http_router.cpp, the branch ininbound_request.cpp's per-PDU switch that invoked it, andfederation::apply_state_resolution_v2(its one production caller was that same dead lambda).PduIngestionStatus::rejected_state_conflictis kept as a legacy enumerator (no production sink produces it any more) purely so the worker IPC wire format stays a stable, exhaustive set;resolve_state_v2itself is untouched — phase B1'scompute_state_before/recompute_current_stateare its only callers now. The one test that exercised the deleted resolver (tests/unit/test_federation_membership_endpoints.cpp, "State-resolution v2 helper merges forked state when groups disagree") is removed with it. - v12 (MSC4291) state resolution fix found while testing this phase:
resolve_state_v2's v2.1 iterative auth checks start from an empty running state (rooms/v12.md, "State resolution", modification 1) and no v12 event's ownauth_eventsmay namem.room.create(rule 3.2 — see the conformance test below). Together these meantbuild_auth_event_map_ from_state's create slot could never be filled for a v12 candidate by either its running-state lookup or its own-auth_eventsfallback: every v12 candidate's iterative auth check failed Step 2 ("room has no create event"), so no v12 room's state could ever be resolved at all once a real fork required iterative resolution — masked in existing tests only because their hand-built fixtures happened to name create inauth_events(itself a separate, now-fixed rule-3.2 violation). Fixed by seedingresolved'sm.room.createentry fromunconflictedat v2.1 initialization: a room's create event cannot genuinely be in dispute (exactly one per room, agreed by every submitted state group — that agreement is why it is inunconflicted, neverconflicted), so this does not reintroduce anything modification 1 exists to guard against. Seedocs/event-engine.md, "Phase B1: state resolution wired into ingestion" (auth difference section). - Tests:
tests/unit/test_pdu_ingestion_auth_checks.cpp([pdu_ingestion][auth]) — a bad-hash PDU redacted and accepted; a PDU passing current-state auth but failing its ownauth_events, rejected; one passingauth_eventsbut failing state-before, rejected; the ban-evasion case (prev_events preceding the ban pass bothauth_eventsand state-before but fail current state) soft-failed rather than hard-rejected; a rejected event's after-state equal to its state-before with a later event still able to reference it; a soft-failed event's after-state includes itself (distinguishing it from a rejected event's, which does not — whether any particular fork resolves in a soft-failed candidate's favour is a state-res v2 question covered bytest_state_resolution_auth_diff.cpp/test_state_resolution_conformance.cpp, and a banned sender's soft-failed event correctly does NOT win a real resolution either, per spec "Soft failure"'s own anti-injection guarantee); the threeauth_events-selection violations; and a mixed-outcome transaction. Plus newtests/conformance/test_pdu_ingestion_conformance.cpp([conformance][federation][auth][room-v12]): a v12 PDU namingm.room.createinauth_eventsis rejected (rooms/v12.md rule 3.2), and the same PDU with create correctly omitted is accepted. See ADR-0064. - Not done in this phase: the membership-acceptor path
(
send_join/send_leave/send_knockacceptance,src/federation/inbound_request.cpp's membership handler) still hard- rejects a content-hash mismatch (403) rather than redacting, and does not run the auth_events/state-before/current-state three-way check — it predates ADR-0064 and is a materially different trust boundary (a single trusted join response, not a transaction of arbitrary PDUs). Tracked as a follow-up, not silently left inconsistent.
0.12.12¶
Documentation only — no code changes beyond the version bump. A full audit of every project document against the code found and corrected stale or wrong statements, and filled the gaps agents and operators were tripping over.
Agent guides (AGENTS.md)¶
- Root
AGENTS.md: the project layout omitted five modules (appservice,federation_worker,identity,ipc,push) and two test directories; the key entry points named files that do not exist (src/homeserver/main.cpp,homeserver/server.hpp) and omitted three of the four executables; the glossary pointed at wrong headers for power levels, auth rules and room versions. The project overview is reworded. - New module guides for
src/federation_worker/,src/ipc/andsrc/push/, plus the missingCLAUDE.mdshims for those and forsrc/appservice/andsrc/identity/, whose guides were never loaded. - Corrected module guides:
auth(UIAA state is in memory, not anauth_sessionstable; token hashing is BLAKE2b, keyed for v3/v4),config(the reload-restart list was backwards for rate limits and log modules),crypto(libsodium boundary matchesreject-unsafe.sh; nine undocumented files),http(three files mapped to the wrong responsibility),net,media(upload default is 50 MiB),homeserver(twelve undocumented files; the upload pipe format described a fixed bug),federation,platform(an ELF-probe failure blocks start-up, it does not warn),rooms(the server does not reject plaintext in encrypted rooms),sync,trust_safety,observability,events, and the test, script and fixture guides. docs/AGENTS.mdnow lists which documents to update for config, logging, dependency, endpoint and new-module changes.
Operator and design documents¶
user-manual.md: eight wrong config defaults, an incomplete reload-policy table, missing exit codes 80 and 81, and a new Admin API section.observability-audit.md: federation audit events are held in memory only and never reachaudit_logorGET /_merovingian/admin/audit; this is now stated, and the audit catalogue is complete.security-review-checklist.md,security-coding-rules.mdandthreat-model.mdnow cover the federation-worker IPC channel, Application Service API, Identity Service client, push gateway delivery and cross-signing over federation; the checklist no longer claims remote media fetch is disabled.- Stale "not implemented" claims removed across
auth-identity.md,canonical-json.md,crypto-boundary.md,http-transport.md,event-engine.md,database-persistence.md, the dependency notes and the capability trackers; wrong facts corrected inarchitecture.md,build-warning-policy.md,release-process.md,log-filtering.md,trust-safety.md,coding-rules.mdandREADME.md. - Seventeen broken links into the local Matrix spec copy fixed.
Gaps now recorded in docs/todos/capability-gaps.md¶
- No soft-fail check on inbound PDUs.
- Room versions 1 and 2 are registered, but their event-ID format is not implemented.
- User reporting (
POST /users/{userId}/report) and redaction (PUT /rooms/{roomId}/redact/...) are not routed. - Three admin routes are declared but unreachable, and
http::Serveris dead code. - Thirteen further v1.19 Client-Server operations are not routed (among them
admin/whois, URL previews, password-resetrequestToken,login/get_tokenandtimestamp_to_event).matrix-v1.19-client-server-api.mdand its generator now state that the file is a spec reference, not a status tracker.
0.12.11¶
- Fix federated room joins failing with HTTP 500
event signing failedwhen the resident server'smake_jointemplate already containshashes. Replace the template's hashes with the freshly calculated content hash before signing, rather than adding a duplicate JSON member. Apply the same correction to federated leave templates. - Add regression coverage for membership templates with and without existing hashes, checking the outgoing content hash and local event signature.
0.12.10¶
Fixes devices of users on other servers always showing as unverified, even when their owners had verified them. A client decides a device is verified when its owner's self-signing key has signed it, and a user is verified when the viewer's own user-signing key has signed that user's master key. Every link in that chain was being lost between servers, in both directions.
Your users looking at remote users¶
- The
/keys/queryfederation proxy threw away remote cross-signing keys. It copied onlydevice_keysfrom the remote/user/keys/queryresponse and discardedmaster_keysandself_signing_keys, so a client could never check a remote device against its owner. They are now passed through. - Your own verification of a remote user never stuck. The user-signing signature a client uploads over a remote user's master key is stored here, never on the remote server. It is now merged back into that master key for the user who made it, and for nobody else (spec: signatures "that the requesting user is allowed to see").
- The proxy trusted whatever the remote server said. Any user ID in the
response, on any server, was passed to the client. A malicious server could
have injected devices, and after this fix a master key, for users it does not
own.
federation::accept_remote_key_query_response()now keeps only the users that were asked about, and only keys that describe the user they are filed under. - Inbound
m.signing_key_updatewas dropped as an unknown EDU type, so a remote user resetting their cross-signing never reached local clients. It now puts the user indevice_lists.changed, asm.device_list_updatedoes.
Remote users looking at your users¶
- Keys served over federation carried no uploaded signatures.
/user/keys/queryand/user/devices/{userId}sent device keys without the owner's self-signing signature, so remote servers saw every device here as not verified by its owner. - Outbound
m.device_list_updatecarried unsigned keys. Remote servers such as Synapse apply the EDU'skeysdirectly to their cache whenprev_idis empty, so they cached the unsigned form. m.signing_key_updatewas never sent. Remote servers that serve/keys/queryfrom their cache never learned your users' cross-signing keys. Uploading a master or self-signing key now sends it to every server the user shares a room with. An upload of only the private user-signing key sends nothing.
Both directions¶
- Signatures on cross-signing keys were never found.
/keys/signatures/uploadaddresses a cross-signing key by its unpadded base64 public key, as in the spec's request example. The lookup useded25519:<key>, so signatures on master keys, including every user-signing signature, were stored but never returned. The conformance scenario "POST /keys/signatures/upload then POST /keys/query returns the uploaded signatures" had uploaded with the prefixed form, which is not the spec's and is not what clients send. Its request body now uses the spec form; its assertions are unchanged. - Signature visibility is now defined and applied everywhere. All paths
that serve keys go through
federation/key_signatures.hpp. An upload by the key's owner is public. Any other upload is visible only to its uploader. Only the uploader's own signer entry is ever merged, so no upload can publish a signature under another user's name. Before, every stored signature was shown to every local requester. ADR-0060. - Remote users' keys are proxied live and never cached; inbound
m.signing_key_updateis a change signal only. ADR-0061.
Tests¶
- New
tests/unit/test_federation_key_signatures.cpp: key-ID form, owner and viewer visibility, forged signer entries. tests/unit/test_federation_key_query.cpp: signatures in/user/keys/queryand/user/devices; remote response filtering (injection, mismatcheduser_id/device_id/usage, non-object bodies);m.signing_key_updateandm.device_list_updatecontent.tests/unit/test_inbound_ingestion.cpp:m.signing_key_updateclassification and content validation.- New
tests/integration/test_federation_cross_signing_flow.cpp: the proxy against a real TLS peer (remote cross-signing keys returned, the requester's own signature merged, injected users dropped, and a third user not seeing the signature);m.signing_key_updatesent on upload, not on a USK-only upload; inboundm.signing_key_updatereaching/sync, and rejected from the wrong origin. tests/conformance/test_client_server_conformance.cpp: a user-signing signature over a master key is shown only to its signer.
0.12.9¶
Fixes all 33 confirmed findings of the September 2026 security audit
(docs/security-audit-report-2026-09.md). Every finding was checked against the
code and against the v1.19 spec before any change was made. Three did not
survive that check as stated and were implemented differently; each is called
out below, because in every case the audit's suggested remedy would have looked
complete while being wrong.
High severity¶
- H-01
POST /_matrix/client/v3/refreshauthenticates with a refresh token, so it never reached the access-token moderation gate. A locked account could keep minting access tokens indefinitely, defeatingM_USER_LOCKEDon every endpoint except logout.refresh_local_sessionnow refuses a locked account with401 M_USER_LOCKEDandsoft_logout: true.
Departure from the audit: suspended accounts are deliberately still served.
The spec SHOULD-lists logging in, creating further sessions, and reading
through /sync and /messages among a suspended account's permitted actions,
and this server's own suspension allowlist already permits POST /login —
which mints unlimited fresh access tokens. Refusing a rotation while permitting
a fresh login buys nothing and denies the reads the spec asks servers to
preserve. See ADR-0058.
-
H-02 Refresh no longer resurrects a deleted device. The token lifecycle binds every credential to a device row; a refresh token that outlives its row can only have arrived through a failure, and honouring it reanimated a session for a device the user believed was gone.
-
H-03 Accepted plain-HTTP sockets were left blocking. Reads were guarded by the HTTP layer's
poll(POLLIN), but the response write was not, so a peer that accepted a connection and then stopped reading parked a worker inside::send()indefinitely — one client could exhaust the pool. ADR-0054 fixed this for TLS and stated the rule; plaintext was left out. Accepted sockets now carrySOCK_NONBLOCKfromaccept4itself, and both send paths wait for writability against a deadline. -
H-04 Per-PDU rejections incremented a local copy of the peer's consecutive-failure count, and the handler then zeroed it unconditionally at transaction completion. A peer could send endless transactions of forged PDUs — each answered
200with per-PDU errors — without ever tripping the backoff or circuit breaker. The count now survives any transaction in which a PDU failed a trust check; only a fully clean transaction resets it. Room-ACL denials are excluded, because an ACL is local policy rather than evidence of peer misbehaviour. -
H-05 The event signer logged the full canonical signing payload and the complete signed event JSON at debug level under field names the redactor does not recognise. Diagnostics now carry each payload's byte count and SHA-256 digest only — still enough to compare byte-for-byte with a peer when triaging a
BadSignatureError. -
H-06 V2 state resolution never populated
authorising_user_member, which the restricted-join authorization branch requires, so every valid restricted join in the conflicted set was rejected and room state diverged across servers. -
H-07 V2 state resolution read power levels with an integer-only accessor. Room versions 1–9 permit string-encoded levels and the authorization path already parses them, so the resolver silently demoted such senders to
users_default— letting a lower-power event win the ordering. It now respects the room version policy, matching the auth path.
Medium severity¶
- M-01 The Argon2id hash of the registration token file was cached by path and never revalidated, so rotating the token on disk kept the old one live until restart. The cache is now keyed on the file version (device, inode, size, sub-second mtime) and only stores an entry when the file did not change between the two stats bracketing the read.
- M-02 The rate limiter's own bucket tables used
std::hash<std::string>over attacker-influenceable keys, so one precomputed collision set degrades every deployment's table into a linear scan. Keys now hash through keyed BLAKE2b at the crypto boundary under a per-process random key. - M-03 Parser errors, timeouts and head-too-large responses carried no CORS
headers, so a browser saw them as CORS failures instead of the real status
(spec §10.5). The
Originis now read out of the raw head, which is the only source available when the head never parsed. - M-04 v1 invites fell back to room version 12 for the reference-hash event
ID and the Ed25519 check. The spec requires a v1 invite to be assumed room
version 1 or 2; the room's real version is now resolved from local state as
send_joindoes, falling back to v1. - M-05
verify_inbound_federation_signatureis exported and skipped the Ed25519 check whenever a caller-set bool said it was already verified. It now always verifies; the worker fast path stays inhandle_inbound_federation_request. - M-06 The well-known discovery overload resolved through a literal network that returns a documentation IP for every host, bypassing DNS and the SSRF checks. It now requires an explicit test-only opt-in token, so a production call site written against the old signature fails to compile.
- M-07 / L-07
RuntimeEd25519Providerhanded its storedSecretBufferstraight to libsodium with no size check. The constructor is the trust boundary where forgery-capable material enters, so a secret that is not exactly 64 bytes is now never retained, andsign()keeps its own guard. - M-08 The identity-server unbind credential pair (
client_secret,sid) was bound as a public value and could reach a query trace or diagnostic log. Both are now sensitive bindings.
Scope note: the audit also asked for encryption at rest or a redesign around
homeserver-signed unbind. That is a schema and protocol change, not a binding
fix, and remains the documented residual risk in docs/threat-model.md.
- M-09 PQexecParams sends every parameter as a null-terminated C string, so
a raw binary payload truncates at its first embedded NUL. media_blobs.bytes
is now marked binary and hex-encoded into PostgreSQL's bytea literal on the
way in and decoded on the way out, matching SQLite's length-based binding.
- M-10 Neither backend serialised a migration plan across processes, and each
step commits separately, so two servers starting together could interleave DDL.
PostgreSQL now holds a pg_advisory_lock for the whole plan under an RAII
lease. SQLite cannot hold a cross-process lock spanning its per-step
transactions without deadlocking against them, so it re-reads the ledger inside
each step's BEGIN IMMEDIATE and skips a step the winner already applied. See
ADR-0059.
- M-11 LOG_* passed a caller-built string straight to SingleLog,
bypassing the only secret-redaction boundary the structured diagnostics use.
Every named method now routes its composed line through redact_log_message,
which applies the same sensitivity rule as structured fields.
Low severity¶
- L-01 / L-02 Both UIAA challenges carried a compile-time constant session id shared by every client and every attempt. Challenges now mint a random per-attempt id, bounded and TTL-pruned, scoped to the endpoint that issued it; a client that supplies a session must supply one we issued for that endpoint. Omitting it entirely is still served, because the spec permits completing a single-stage flow in one shot. Multi-stage tracking is deliberately not implemented — there is exactly one configured stage, so there is no ordering to enforce. See ADR-0057.
- L-03
device_id_is_validaccepted:— the key-ID separator, which makesed25519:a:bambiguous — along with/,?and#, which break the/devices/{deviceId}path segment. - L-04 The TLS 1.2-and-below cipher list still permitted plain-RSA key exchange, so a later compromise of the server's key could retrospectively decrypt recorded traffic. Restricted to authenticated ephemeral AEAD suites; TLS 1.3 ciphersuites are configured separately and are unaffected.
- L-05
apply_cors_headerspairedAccess-Control-Allow-Credentialswith a wildcard origin, which CORS forbids.
Correction to the audit: this was not reachable through configuration —
config::validate already refuses wildcard-plus-credentials and the server
will not start with it. The fix is defence in depth for a runtime CORS snapshot
that never passed through validation.
- L-06 The backfill limit reached the injected provider unclamped; it is
now capped at 100.
- L-08 parse_u32 in the db-migrate CLI folded empty input, non-numeric
text and overflow all into 0, so --plan abc def printed an empty plan and
exited 0.
- L-09 The audit sink was a plain function pointer written during thread
startup and read from arbitrary threads with no happens-before edge. It is now
a lock-free atomic.
- L-10 local_audit_sink no-opped silently whenever the thread-local
database was unset or closed. It now warns once per drop episode rather than
once per dropped event, which would have made a flood worse.
- L-11 AuditLogEvent.append_only asserted a guarantee nothing enforced.
Removed rather than left lying; real enforcement is a database trigger, which
belongs in its own migration.
- L-12 string_format forwarded a runtime string as a printf format
(CWE-134). It and the LOGF_* macros built on it had no call site in src/,
so both are deleted outright.
- L-13 The bounded console and file log queues discarded messages with no
signal. They now count drops and emit one warning per episode, written straight
to stderr so the warning about a full queue is not itself queued.
- L-14 signing_key_id_is_valid accepted any printable key id; it now
delegates the ed25519: prefix and shape check to the crypto boundary.
- L-15 make_content_hash_id hardcoded room version 12 regardless of the
event's actual version; it now takes the policy from the caller.
Found while reviewing the suspension allowlist¶
- Account suspension was bypassable through a client-chosen path segment.
Not an audit finding — found while answering a question about what suspended
accounts may still do.
action_allowed_while_suspendedpermitted its three per-room actions by searching for/leave,/messagesand/redactanywhere in the request path, and the gate sees the raw undecoded target. Several room endpoints end in a segment the client picks: the transaction ID ofPUT /rooms/{roomId}/send/{eventType}/{txnId}and the state key ofPUT /rooms/{roomId}/state/{eventType}/{stateKey}. A suspended user who named their transactionredactcould therefore send arbitrary messages, and one who named a state keyredactcould write room state — both returning200, defeating suspension for the two actions it most exists to block.
The allowlist now matches the action segment — the path segment immediately after the room ID — by exact comparison. Anything added to this gate in future must be anchored to a segment the client cannot choose.
Note also that the gate cannot enforce the spec's "redact their own
events", because it cannot see the target event's sender. That endpoint is not
routed today (404 M_UNRECOGNIZED); whoever implements it owns the ownership
check, and the comment at the gate says so.
- The suspension allowlist admitted more key endpoints than the spec permits.
Also not an audit finding. The spec's permitted-actions list names two
key-related capabilities — "Verify other devices and write associated
cross-signing data" (§Device verification, §Cross-signing) and "Populate their
key backup" (§Server-side key backups). Between them those sections name
exactly
POST /keys/query,POST /keys/device_signing/upload,POST /keys/signatures/upload, and the/room_keys/subtree.
The gate admitted the whole /keys prefix, which additionally allowed
POST /keys/upload (publishing the account's own device and one-time keys),
POST /keys/claim (claiming a one-time key to open an Olm session) and
GET /keys/changes. The first two are participation rather than
verification. The permitted endpoints are now listed exactly, with their
methods, and /room_keys gained a trailing slash so the prefix cannot match
a longer path segment.
Also fixed¶
- Three defects the branch's own first verification run surfaced in its new
tests: a dangling pointer into a
parse_objecttemporary, a CORS test whose config could never start, and a race betweenacceptand the worker's first I/O.
0.12.8¶
- Publish parallel security audit report covering 33 confirmed findings across
auth, client HTTP, federation, crypto, database, observability, and event
engine surfaces, with adversarial verification and actionable acceptance
criteria (see
docs/security-audit-report-2026-09.md). - Apply configured module/default log levels to direct logging macros as well as structured diagnostics, including when console debugging is enabled.
- Emit one diagnostic warning per client rate-limit rejection while preserving both policy audit records and the HTTP 429 response with retry information.
- Serve the Matrix v1.19 federation version endpoint without authentication, avoiding spurious authorization warnings for ordinary version discovery.
- Raise the built-in thumbnail-only rate limit from 20 to 60 requests per minute for normal client rendering bursts, while retaining existing upload and full-download limits, and document scoped operator tuning.
0.12.7¶
Fixes the eight findings of the 0.12.6 security audit. All eight were verified against the code and against the v1.19 spec before any change was made; none were misreads.
Two of them are only meaningful together, and two of them were not what the audit's suggested remedy would have produced. Both cases are called out below, because in each the obvious fix would have looked complete while leaving the hole open.
M-01 (critical): federation membership endpoints persisted state without authorizing it¶
send_join, send_leave and send_knock verified the inbound PDU's Ed25519
signature, its content hash, and its sender/origin consistency — and then
persisted the event and the membership row after checking only that the room
existed. Signature verification establishes who signed an event; it never
establishes whether they are permitted to make the transition. Any remote
server holding a valid signing key could therefore join a user into an
invite-only room, or move a membership it had no power level to move, simply by
presenting a correctly signed PDU.
The ordinary /send transaction path already got this right: ingest_pdu_event
builds an auth-event map and runs authorize_event_against_auth_events before
persisting. The membership acceptor now runs the identical gate, before anything
reaches the store. A rule enforced on only one of two paths into the same store
is not enforced at all.
M-02: SRV delegation moved the TLS identity to the SRV target¶
Spec §Resolving server names, steps 4 and 5: "Requests are made to the resolved
IP address and port, with a Host header of <hostname>. The target server must
present a valid certificate for <hostname>." <hostname> is the original
server name — the spec follows those steps with a section explaining precisely
why the SRV target must not be used instead.
ServerDiscoveryResult had no field distinguishing "where the packets go" from
"whose certificate must be presented", so every outbound URL was built from
resolved_host — the SRV target. SRV records are unsigned DNS, so anyone able to
forge a DNS response could redirect a homeserver to a host presenting a
certificate for a name it legitimately controls, and be trusted as the origin
server. That includes /_matrix/key/v2/server, the root of trust for every
signature that server ever makes.
ServerDiscoveryResult::tls_server_name now carries the certificate identity —
the original hostname for the SRV and direct paths, the delegated hostname under
well-known delegation, never the SRV target. Outbound URLs are built from it,
while pinned_addresses continues to route the connection to the discovered
target via CURLOPT_RESOLVE, so SNI, Host and certificate validation all sit
on the authoritative name. resolve_destination takes the certificate host as a
required parameter rather than deriving it, so a future discovery branch cannot
silently inherit the wrong one. All three production URL builders were affected,
not only the key cache cited in the finding.
M-03: named room-state reads were not authorized at all¶
GET /rooms/{roomId}/state/{eventType}/{stateKey} checked that the room existed
and then returned the state event's content to any authenticated user. The
two sibling handlers directly above it in the same file both perform a proper
membership check; this one did not. Its 403 text ("not a member of this room")
was also returned for a room that simply did not exist.
Per the spec, a joined member now reads current state, and a user who has left reads the state as it stood at their leave point — not the current value, which would leak everything written after they left. Everyone else, including a user with no membership row and a room that does not exist, receives an identical 403, so a non-member cannot distinguish the two. A ban denies access outright: the spec sentence names "joined" and "has left", and a banned user should not get more than the full-state endpoint gives anyone who is not currently joined.
M-04: logout left the device's refresh token usable¶
Single-device logout revoked the access token and flipped the in-memory session,
but never touched the refresh token. refresh_local_session admits any refresh
row that is merely unrevoked, so the client that had just logged out — or anyone
holding a stolen refresh token — could immediately mint a fresh access token.
Logout was cosmetic. logout_all, device deletion and password change all
revoked refresh tokens already; single-device logout was the one path that did
not, and now does.
M-05: password change resurrected previously revoked tokens¶
With logout_devices (the default), the password-change flow revoked every token
for the user and then restored the caller's own device with an unfiltered
UPDATE access_tokens SET revoked = false WHERE user_id AND device_id.
That statement cannot distinguish a token it revoked microseconds earlier from one revoked days earlier by a logout, an admin action, or a previous password change. It un-revoked all of them. A password change is the action a user takes after discovering a compromise, and it handed the attacker's revoked token back.
The revoke-then-restore pair is replaced by
revoke_tokens_for_user_except_device, which never touches the caller's device,
so there is nothing to reinstate. restore_tokens_for_device is deleted
outright: no code path can now clear a revoked flag. See ADR-0052 — revocation
is one-way, and that is a rule for future code, not just a fix here.
M-06 and M-07: request-body and TLS read deadlines (these two only work together)¶
M-06: request-head reads enforced both an overall deadline and an inter-byte
cap. Request-body reads enforced neither — only a fresh 15-second poll per
4096-byte chunk, so a client dribbling a declared Content-Length could hold a
worker thread for roughly (bytes / chunk) x 15s without ever timing out. The
body now carries both caps, with the overall deadline scaled by the declared
length at a 16 KiB/s floor so that large media uploads are unaffected while a
dribbled 1 MiB body is cut at about 94 seconds instead of 65 minutes.
M-07: TLS sockets were restored to blocking mode after the handshake, and
TlsConnection::read called SSL_read_ex on them. The HTTP layer's
poll(POLLIN) proves that TCP bytes are available — never that a complete TLS
record is. A peer sending one byte of a record made the socket readable, entered
SSL_read_ex, and blocked in the kernel indefinitely: past the poll deadline,
past the head deadline, outside every timeout the server believed it was
enforcing. One connection could park one worker thread for as long as the
attacker liked.
Sockets now stay non-blocking for the life of the connection, and one shared
pump() drives WANT_READ/WANT_WRITE for both reads and writes against a
deadline. M-06 is inert on TLS listeners without M-07, since a body deadline
means nothing while a single read beneath it can block forever — which is why
they ship together. See ADR-0054.
A cap that could never fire. Writing the socket-level cover for these two
turned up a defect in the M-06 fix itself. The slowloris caps are only re-checked
between reads, but recv_with_timeout always polled the full 15-second
per-read window — so one poll outlived the 5-second inter-byte cap and that cap
could never fire. A client stalling mid-body was still released at 15 seconds,
exactly the pre-fix timing. The overall deadline worked; the minimum-progress
half did not.
recv_with_timeout now takes a poll budget, and both read loops pass the
smallest of the per-read timeout, the overall deadline, and the remaining
inter-byte allowance. Budget expiry is reported distinctly from a peer close, so
the loop re-checks and the cap that actually expired is what ends the request and
gets logged.
This applies to the request head read as well, which had the identical structure. Its comment claimed the caps bound the worst-case worker hold time "regardless of how cleverly the client dribbles bytes" — they did not. Fixing only the body would have left a provably inert cap beside the one being repaired.
M-08: the thumbnail decoder ran under the general server syscall filter¶
The thumbnail worker exists so that a libpng or libjpeg-turbo memory-safety bug
is contained rather than fatal. OpenBSD confines it with pledge("stdio") and
FreeBSD with cap_enter() — neither grants filesystem, socket or
process-creation access. On Linux it installed apply_seccomp_filter(), the
general server allowlist, which permits socket, connect, sendto,
openat, unlinkat, execve and clone. On the primary production platform
the decoder was effectively unconfined, while the file's own comment claimed a
decoder exploit had "nothing to reach for".
The existing stricter apply_worker_seccomp_filter() is not a substitute,
and using it would have looked like a fix while changing almost nothing: it was
built for the federation worker, removes only execve/execveat, and still
permits sockets, openat and clone. A new decoder profile now matches the
pledge("stdio") boundary, so all three platforms confine the decoder
equivalently. A regression test asserts the decoder profile is strictly tighter
than the worker profile, so a later "simplification" onto the shared filter fails
the suite instead of silently reopening this. See ADR-0053.
Decisions recorded¶
- ADR-0052 — revocation of credentials is one-way; no database function may
clear a
revokedflag. - ADR-0053 — sandbox profiles are named for the threat they contain, not for the kind of process that installs them.
- ADR-0054 — TLS sockets stay non-blocking for the life of the connection; nothing below the HTTP layer may perform a blocking I/O call on a connection descriptor.
0.12.6¶
Closes issue #487: the hand-written guard.unlock(); f(); guard.lock(); pattern
around HomeserverRuntime::mutex is gone, and so is the defect class behind it.
A third instance of the recursive-release stall, found and fixed¶
runtime.mutex is recursive so that service functions such as create_room,
join_room, leave_room and invite_user_by_threepid stay independently
callable. When one of them runs beneath a request handler that already holds
its own guard, unlock() drops one recursion level and not the lock, so the
mutex stays held for the whole of the "released" network call and every other
client request and inbound federation transaction stalls behind it.
That had already shipped twice (create_room in 0.12.1, leave_room in
0.12.3). Writing the regression test for #487 first turned up a third, still
live: invite_user_by_threepid released only its own guard around the
identity-server /store-invite round trip. An ordinary authenticated client
inviting an email address through an operator-trusted identity server froze the
whole server for the length of that call. The new scenario in
tests/integration/test_request_lock_contention_flow.cpp drives a real TLS
identity server that accepts and then withholds; against the old code an
unrelated request took 8001 ms against a 3000 ms budget.
One release primitive that cannot be misused¶
HomeserverRuntime::mutexis now ahomeserver::RuntimeMutex— astd::recursive_mutexthat also records its owning thread, so a release scope can ask whether the calling thread still holds it.std::recursive_mutexcannot answer that, which is why the old mechanism could not be made safe.NetworkIoUnlockandScopedGuardReleasecollapse intohomeserver::RuntimeLockRelease. The two differed in which guard they acted on — the thread's published one versus one in hand — and at a nested call site those are different objects. Picking the wrong one compiled, passed its tests, and held a global mutex across a network round trip; that distinction is exactly what made theleave_roomfix incomplete. The two constructors still name the choice, but both release every recursion level the calling thread holds and restore exactly that many on scope exit, throwing paths included.- Releasing a caller's guard before delegating to a self-locking function is no longer load-bearing. The existing call sites that do are kept because they are explicit, not because correctness depends on them.
join_room's released region is now a function¶
join_room's release spanned roughly 350 lines, and nine of the forty-four
values declared inside it were consumed after the re-lock. A mechanical
conversion would have hoisted those nine above the scope: const stripped from
each, every type forced to be default-constructible, nine initialisations split
into declare-then-assign inside a 1000-line function. That is why it had been
deferred.
It is now extracted into perform_federated_join, returning a
FederatedJoinOutcome that carries exactly those nine values plus an optional
failure the caller returns verbatim. The lock boundary is the function
boundary, nothing needs hoisting, and the release is a two-line scope around the
call. invite_user_by_threepid and the three local_http_router.cpp sites use
the same idea at smaller scale, with an immediately-invoked lambda.
Everything else converted¶
local_http_router.cpp: the persistent-store commit release, thecreateRoomdelegation, and the join delegation are allRuntimeLockReleasescopes. Each had an early return or a self-locking call inside the released region that a throw would have stranded.- No hand-written release of
runtime.mutexremains ininclude/orsrc/. The twounlock()calls that survive the gate are the make_join race's notify-outside-the-lock idiom on its own local mutex. scripts/reject-unsafe.shnow matches->unlock()as well as.unlock()— a release through a pointer used to slip past unseen — and exempts by name the two files that implement the primitives.
Fixed in review on #490¶
Four findings from review, all in code this branch introduced:
- Nested release scopes corrupted the recursion depth. The scope released
the named guard through
unique_lock::unlock()and drained the remaining levels through the mutex. Draining cannot clear anotherunique_lock's ownership flag, so an inner scope reaching for the thread's published guard saw a staleowns_lock() == true, unlocked a mutex the thread no longer held, underflowed the depth, and would have strandedruntime.mutexlocked for the life of the process. The scope now drops every level through the mutex and never consults a guard's flag; nested scopes readheld_by_current_thread()and correctly find nothing to release. The new scenario fails against the old shape withOperation not permitted. perform_federated_joincaptured a danglingkey_id. Extracting the function turnedkey_idinto astd::string_view, soauto key_id_copy = key_id;deduced a view rather than the owningstd::stringthe surrounding comment promises. make_join tasks that lose the race are moved intoruntime.orphan_futures_and outlivejoin_room's frame, so a slow or semaphore-queued candidate could read freed memory when building its X-Matrix authorization header.FederatedJoinOutcomeheld the room-version policy as a raw pointer, making the caller's dereference depend on a separatefailurefield and breaking the project's no-raw-pointers rule. It is held by value now; the background-fill task already copied it.- The reject-unsafe gate exempted two files wholesale, which would have let
any future unannotated release in either primitive through, and
scripts/AGENTS.mdsays not to add exceptions to that script. The exemption is per-line again, with the sameLOCK_RELEASE: reviewedannotation every other site would need.
Architecture Decision Records introduced¶
docs/adr/ is new: Architecture Decision Records in the MADR 2.1.2 format,
following the layout and template of the
Opinionated Digital Center's ADR repository
— docs/adr/NNNN-title-in-lowercase-with-dashes.md, a table of contents in
docs/adr/index.md, the template in docs/adr/template.md, and .adr-dir at
the repository root so adr-tools and pyadr find the directory. Status and
date are mandatory.
Fifty-two records. ADR-0000 and ADR-0001 record the practice and the format; ADR-0002 to ADR-0008 record this branch's decisions; ADR-0009 to ADR-0051 are backfilled from existing documentation and code, covering concurrency, canonical JSON, crypto and secret handling, transport and rate limiting, media, appservices, sync, database roles, platform support, and process. Each carries a note that its date is the date of the record, not of the decision.
Backfilling turned up one documentation defect: docs/event-engine.md stated
that signing keys are regenerated on every restart, "effecting automatic key
rotation". They are not — find_active_server_signing_key selects the stored
key and republishes a lapsed valid_until_ts window rather than replacing it,
and silent regeneration is prohibited. Corrected, and recorded as
ADR-0017.
Two exist because the code depends on them without saying so locally, which is the case the format earns its keep for:
- ADR-0004
— for the lifetime of a release scope, every
std::unique_lockonruntime.mutexreportsowns_lock() == truewhile the mutex is actually free. That is sound only because nothing outsiderequest_lock.cppreads the flag. Introducing such a read anywhere else breaks it, and no compiler will say so. - ADR-0003
— a callee's release scope now drops a lock its caller was holding. The blast
radius was checked once (
ingest_pdu_eventis the only candidate site, and neither caller holds a level); a new caller of it that holdsruntime.mutexreopens the question.
docs/AGENTS.md otherwise forbids new documents; adding an ADR is now the one
stated exception, since the format's value comes from an immutable file per
decision. Registered in docs/AGENTS.md and the root AGENTS.md key-docs
list, and the root AGENTS.md General Rules now carry the standing rule:
record a decision in the register whenever you make one, with the test for
what counts as a decision and the requirement that entries are never
renumbered or deleted.
Tests¶
tests/unit/test_request_lock.cpprewritten to assert on what another thread can observe, not on what a guard believes. A recursive mutex answerstry_lock()with "yes" to its own owner at any depth, so only an outside probe distinguishes "released" from "one level released". New scenarios cover a nested dispatcher/service pair, the published-guard constructor over an unpublished nested guard, the throwing path in both, andRuntimeMutexownership reporting.tests/unit/test_request_lock.cppalso covers scope nesting: an inner release scope opening inside an outer one, and a fresh lock taken inside a released region and then released again. Both assert the depth is restored exactly once per level by unwinding the guards and checking ownership ends.tests/integration/test_request_lock_contention_flow.cppgains the third-party-invite scenario described above.
0.12.5¶
Fixes for the security audit recorded in
security/audit-findings-2026-09-04.md. The findings file itself was added
earlier on this branch; this entry covers the code changes.
Signing-secret lifecycle (findings 1-7, 16, 20)¶
- The plaintext server signing-secret fallback is gone. A server with no
security.secrets.master_key_fileused to persist its Ed25519 seed as base64 withencrypted='false', so anyone who exfiltrated the database obtained a forgery-capable federation signing key — the exact threat that column exists to defend against. Both first generation and rotation now fail closed. The audit cited only the generation path; the rotation path carried the same fallback and was fixed with it. - A master key is now required to start. With no signing key a server cannot
sign an event or a federation request, so
start_runtimerefuses to start and says why, rather than surfacing three steps later as a failure to derive an unrelated pagination-token key. Legacy plaintext rows are still read, so an existing deployment keeps federating and can rotate on its own schedule. - An unlockable master key is refused.
load_master_key_material()tolerated a failedsodium_mlock()and returned the root secret in swappable memory after a one-time warning.RLIMIT_MEMLOCKexhaustion therefore silently downgraded every key derived from it. This reverses the #487 decision to keep the condition non-fatal: an unlocked root secret is a crypto-boundary failure, and the remedy (raiseRLIMIT_MEMLOCK, or grantCAP_IPC_LOCK) is a deployment change. - Ed25519 secrets live in
core::SecretBufferend to end.Ed25519Keypair,RuntimeEd25519Provider,RuntimeMultiKeyEd25519Providerand the return ofsecret_box_decryptall held 64-byte seeds in plainstd::array/std::vector— unlocked, never zeroised, and in the provider's case for the whole life of the process.crypto_sign_keypairnow generates straight into the locked buffer andcrypto_secretbox_open_easydecrypts straight into one, so no intermediate unprotected copy exists at any point.rebuild_signing_providerno longer stages every active secret through astd::vectorof plain arrays. - The signing-secret box key is cached. Encrypt and decrypt each re-read the master key file and re-derived the box key on every call, re-materialising the root secret and churning a 4 KiB mlock/munlock pair per operation. It is now cached against the master key file's identity, the same way #487 fixed the access-token HMAC keys; the identity helper is shared between the two.
- The master key file is read unbuffered.
std::filebufkept its own copy of every byte read in ordinary heap memory that is freed without zeroisation, so wiping our own read buffer still left plaintext root-secret bytes in the process. - The stored signing-key row wipes its own secret (finding 17).
PersistentServerSigningKey.secret_keyis astd::string, so the in-memory store held the signing secret's at-rest form in heap memory freed without being wiped, and every copy or move left another unwiped buffer behind. The field cannot become acore::SecretBufferwithout making the row move-only, which the store's row vectors and query paths depend on it not being, so the row now zeroises the field on destruction, on move, and before an assignment overwrites it. This is a mitigation, notSecretBuffer's guarantee — the bytes are still swappable while live. What protects them at rest is that since finding 1 they are always ciphertext.
Unbounded resources reachable from the network (findings 11-13, 19)¶
- The
ThreadPoolwork queue is bounded. Both accept loops queue one closure per accepted connection into an unboundedstd::queue, so a connection flood grew it until the OOM reaper fired.submit()now refuses pastlisteners.max_queued_connections(default 1024) and the listener closes the refused connection, which sheds load in the one way a client can see and retry. The IPC dispatch pools keep the unbounded default: their producer is the local supervisor, not a remote peer, and dropping a message there would silently lose federation work rather than shed a connection. - Federation typing and receipt EDUs can no longer grow memory without
bound.
typing_usersonly shrank when a previously-seen user senttyping=false, andreceiptswas upserted but never reaped, so a peer could grow either indefinitely by varyingroom_idanduser_id. The real bound is membership: an EDU for a room this server holds no membership row for is now dropped — ephemeral state for a room we are not in has no use, and without the check a peer could mint entries for room ids it simply invented. Hard caps back that up for a peer that is a legitimate member of a very large room, evicting the oldest entry. A receipt transaction legitimately batches several rooms, so an unknown room is skipped rather than failing the whole EDU. - The media repository has capacity limits.
security.media.max_records,security.media.max_total_sizeandsecurity.media.max_size_per_userbound the in-memory index, which previously had no cap on records, bytes, or per-user usage. All three default to no limit. An upload past a limit is refused with 507; nothing is evicted, because clients holdmxc://URIs for what is stored and eviction would break those links rather than shed load. Bytes are counted over stored blobs, so a deduplicated upload consumes a record but no bytes.
High-severity correctness (findings 8, 14, 15)¶
- An SSO
redirectUrlallowlist entry naming a bare origin no longer matches other origins.redirect_url_is_allowed()was a barestarts_with()with no boundary, so an entry ofhttps://client.example.com-- the natural way to write "this whole origin" -- also matchedhttps://client.example.com.evil.test/callback. An attacker who registered that domain received a freshly mintedm.login.token. The character after the matched prefix must now be a URL delimiter (/,?,#) or end-of-string, so a bare-origin entry still works as an origin allowlist and a path-scoped entry still works as a path allowlist. - Federation media downloads derive
Content-Dispositionfrom the inline-safe allow-list.build_federation_media_download_body()hardcodedinlinefor every content type, so a peer's clients were told to render whatever we served -- an executable, a scripted HTML document -- inline in the media origin's context, bypassing the allow-list the local download path applies. The allow-list now lives inmedia::content_type_is_inline_safe()and both paths use it; unknown types fail closed toattachment. - Every manual
runtime.mutexunlock/lock pair around an outbound call is now RAII. Fourteen endpoints released the global runtime mutex by hand before a blocking outbound call and re-took it afterwards. A throw between the two left the mutex unlocked, which serialises every client request and inbound federation transaction behind a lock nobody holds. Each is now aScopedGuardReleasescope -- an immediately-invoked lambda where the call's result is read after the lock is re-taken, a plain block where it is not.
Fail-closed hardening (findings 21-24)¶
- A failed
prctl(PR_SET_DUMPABLE, 0)now fails the core-dump policy. The result was discarded and success reported onsetrlimit(RLIMIT_CORE, 0)alone.RLIMIT_CORE=0stops the kernel writing a core file for an ordinary crash, but it does not clear the dumpable flag, and acore_patternthat pipes to a handler (systemd-coredump, apport) is not bound by the process rlimit — so the process image, master key included, could still be captured while the policy reported success. - Secret files must be owner-read-only.
is_secure_secret_file()never checkedowner_write, so a0600secret passed despitedocs/hardening.mddocumenting owner-read-only since the check was written. Operators mustchmod 0400their secret files before upgrading; the server refuses to start otherwise. Packaging now provisions the registration token as0400owned by the service account, replacing0640 root:merovingian— that mode was already rejected by the group-read rule, so a packaged install could not start with a generated token. server.server_nameis validated against the Matrix grammar.config::validate()checked only that it was non-empty, so values like:8000reached runtime and were used to build user IDs and federation routing keys. It now runs throughauth::server_name_is_valid— the same check applied to every remote server name — so a name this server accepts for itself is one its peers will also accept.- A malformed
expires_atis an expired token, not a permanent one.parse_expires_at()returnednulloptfor an unparseable value, which every caller reads as "never expires", so a corrupt or attacker-modified row became a credential that could not be aged out. It now parses as the epoch, which every expiry comparison reads as long past. The cost of being wrong is one re-login.
BSD platform hardening (findings 9, 10)¶
- The startup hardening self-check no longer blocks BSD startup. Privilege
drop, filesystem restrictions and core-dump policy sat behind an
__linux__guard even thoughgeteuid()andgetrlimit()are POSIX, so a FreeBSD, OpenBSD or NetBSD server reportedunknownfor controls it had actually applied — andis_ready(), which treats anything butenabledas a blocker, then refused to start it. All three are documented Tier 1 platforms. Those probes now run everywhere;no_new_privsand capability bounding, which have no BSD equivalent to probe, use the "not applicable on this platform" idiom the pledge/unveil and Capsicum checks already use on Linux. - BSD servers now clamp
RLIMIT_CORE.setrlimit(RLIMIT_CORE, 0)is POSIX but was only applied on the Linux path, so a BSD server could dump core with its master key and every signing secret in the image. - The thumbnail worker is sandboxed on FreeBSD and OpenBSD. The sandbox step
was seccomp-only under
#if defined(__linux__), so on the BSDs the worker decoded untrusted PNG and JPEG bytes with nothing but rlimits between an image-decoder bug and the rest of the system. It now enterspledge("stdio")on OpenBSD and Capsicum capability mode on FreeBSD — by the timeharden()runs, stdin and stdout are already open and the worker needs nothing else for the rest of its life. NetBSD has no equivalent in-process primitive and keeps rlimits alone; that gap is recorded indocs/hardening.md.
PostgreSQL privilege separation covers migrations (finding 18)¶
- DDL now runs as the migration role, not the login role. Both roles were
provisioned and the request path already dropped to the DML-only runtime role,
but schema migrations ran as the login role — which carries every privilege
both sibling roles have — so the separation did not cover the one operation
that actually needs DDL.
open_postgresql_persistent_store()now takesdatabase.migration_role, assumes it before applying pending migrations, and resets the session before assuming the runtime role. - Fail-closed. A migration role that cannot be assumed refuses the open
rather than falling back to the login role, which would silently restore the
privilege level the separation removes. A pending migration is the only thing
that reaches the role switch, so a misconfigured
migration_rolebreaks an upgrade rather than every routine restart, and leaving it unset keeps single-role deployments working exactly as before. - Upgrading needs one operator step.
ALTER TABLEis permitted only to an object's owner, and objects created before 0.12.5 are owned by the login role — which is precisely why migrations ran as the login role until now. Re-runpackaging/postgresql/provision-roles.sqlbefore upgrading: it ends with a scoped transfer of thepublictables and sequences the login role owns. The startup diagnostic names that step if it was missed, and the PostgreSQL CI job runs the same statements so the sequence is exercised. A fresh install needs nothing.
Not REASSIGN OWNED: that operates on everything the role owns, including
objects the system pins, so it fails outright when the login role is the
cluster bootstrap superuser — which is what the official postgres container
produces and what an operator reusing the postgres role has.
Regression cover for the audit fixes¶
Every finding above now has a test that fails without its fix. The ones that resisted a direct assertion were made testable rather than left uncovered:
- The core-dump policy decision is a pure predicate.
platform::core_dump_policy_is_satisfied()takes the observedRLIMIT_COREpair and dumpable flag, so finding 21 is asserted directly. The live probe cannot be driven from a test: loweringRLIMIT_CORE's hard limit is irreversible for a non-root process, so a test that set it would poison every test after it in the same binary. - The signing-secret key cache is observed through an unreadable file. Making the master key file unreadable while leaving its identity unchanged means a second call that still returns a key can only have come from the cache (finding 3) — no seam required. The scenario skips loudly when run as root, where the permission bits would not bite and it would otherwise pass without proving anything.
- Rotation is exercised through a legacy plaintext row. An encrypted key cannot be loaded without the master key at all, so rotation refuses one step earlier and never reaches the encrypt path finding 1 is about. The scenario rewrites the stored row into the pre-0.12.5 format first, which is exactly the state of a server being upgraded, and asserts on the refusal reason so it cannot pass on an unrelated failure.
- Manual lock release is now a
scripts/reject-unsafe.shgate. Finding 14's fix is structural, so the guard against regression is too: a newguard.unlock()ininclude/orsrc/is rejected unless annotated// LOCK_RELEASE: reviewed — <reason>. The handful of legitimate releases that remain — releasing beforenotify_one, or before a function that self-locks — are annotated rather than refactored: each guard is a function-localstd::unique_lock, so an early exit releases it rather than stranding it, and converting them would buy no safety. - The thumbnail worker round-trip is named as the sandbox guard. Finding 9's restrictions apply to a child process and cannot be asserted from the parent; a correctly sandboxed child is indistinguishable from an unsandboxed one except by what it can still accomplish. The existing integration scenario is that test, on whichever Tier 1 platform's CI job runs it.
docs/security-coding-rules.mdgains the three rules these findings established: secret files are0400, non-crypto modules erase secrets throughcore::secure_zero(), and request guards are released withScopedGuardRelease.
Findings 5 and 20 remain covered only indirectly — both are memory-residue properties, asserted through their observable half (every surviving secret holder is a locked buffer; the unbuffered read returns exact bytes across chunk boundaries) rather than by inspecting freed heap.
Connection-test teardown (sanitizer finding)¶
test_http_server_listener_flow.cpplet the runtime die while pool workers were still using it. All twelve scenarios declared the thread pool before the runtime, so reverse-declaration destruction ran~runtimefirst while a worker could still be insideserve_connectionholding aConnectionContextthat references it. Firing the shutdown signal and joining the accept-loop thread only stops new work being submitted; the per-connection closures already handed to the pool are still in flight, and~ThreadPoolis what joins those. ASan reportedstack-use-after-scopenaming exactly that read.
Fixed in two independent ways rather than one. Each scenario now calls
pool.request_stop() immediately after joining the accept-loop thread, at a
point where the client sockets are already closed so a parked worker sees EOF
and exits promptly — and where a hang, if one ever happened, would name that
line instead of timing out the whole binary. The pool is also declared after
the runtime, so unwind order is correct even if a future scenario forgets the
explicit stop.
Pre-existing and unrelated to the audit: http_server.cpp is untouched on this
branch. It is timing-dependent, which is why the first attempt at this fix
relied on declaration order alone and could not be shown to be safe — see the
commit history for the OpenBSD timeout that followed it and the evidence that
neither proposed mechanism for that timeout survived scrutiny.
Cover the fail-closed branches the audit fixes introduced¶
Coverage measurement over the branch's own added lines found three clusters with no test driving them. Two are now covered; the third cannot be, and is recorded here rather than left to look like an oversight.
- The stored-secret refusal paths. Four branches decide whether the server signs with a key it has actually authenticated, and none was exercised: an encrypted secret with no master key configured, a secret sealed under a different master key (a Poly1305 tag mismatch, meaning a rewritten or tampered row), a secret that decrypts cleanly but is not an Ed25519 secret key's length, and a legacy row holding undecodable base64. Each now has a scenario asserting refusal. Signing with wrong-length material does not fail loudly — it produces signatures no peer can verify — so the length check in particular is worth pinning.
- The new capacity config keys.
listeners.max_queued_connections,security.media.max_total_size,security.media.max_size_per_userandsecurity.media.max_recordsare an operator's only control over how much memory a connection flood or upload spam can consume. Now covered for applied values, explicit0(the documented way to disable a cap), malformed and negative input, and the full unsigned 64-bit range — an overflow to zero would read as "no limit", the opposite of what was asked for. core::secure_zerois pinned directly rather than only through its caller.
Still uncovered, deliberately: the identity-server call sites in
client_server.cpp (they need a live or mocked IS, and the RAII rewrite only
moved pre-existing untested code), the PostgreSQL migration-role path (it needs
a live database, so the Linux coverage job cannot reach it), and two branches in
crypto/master_key.cpp that require sodium_mlock or a key derivation to fail
— neither of which a test can provoke without a seam that would itself weaken
the code under test.
0.12.4¶
Security audit of the 0.12.3 tree. Two of the findings below are privilege escalations reachable by an ordinary room moderator or a federating peer.
- Authorization rule 9 was implemented only for
content.users. Sub-rules 9.1-9.3 (type validation), 9.5 (the scalar keysusers_default,events_default,state_default,ban,redact,kick,invite) and 9.6/9.7 (theeventsandnotificationsmaps) were absent, so the only bound on am.room.power_levelsevent was that its sender met the oldstate_default. A moderator at power 50 could setusers_default: 100, drop their ownusersentry — permitted by 9.8's own-entry carve-out — and resolve to power 100 along with every other member, taking the room. Settingban: 0orkick: 0handed those powers to everyone at less effort. All of rule 9 is now enforced, bounded in both directions as the spec specifies. content.eventsno longer shadows the scalar power-level keys.extract_power_level_keyfell back to looking a missing top-level key up insidecontent.events, which maps event types to levels. Omitting top-levelbanand sendingevents: {"ban": 0}therefore set the effective ban level to zero — a second, independent route to the same escalation, and one that needed no rule-9 gap at all.m.room.createauthorization rule 1 was entirely unimplemented. None of 1.1 (noprev_events), 1.2 (theroom_id/senderdomain relationship, or in v12 that noroom_idis present), 1.3 (a recognisedcontent.room_version) or 1.4 (content.creatorbefore v11;content.additional_creatorsin v12) was checked. Under 1.2 in particular, any federating server could mint a room whose ID claimed another homeserver's domain. The gap was held in place by a conformance scenario asserting that room versions 1-5 have no domain check — a premise the repository's own vendoredrooms/v1.mdcontradicts, and whose fixture put the room ID and sender in the same domain so it never exercised the case either way. That scenario now asserts what it builds, and the mismatching case has its own.- Authorization rule 8 is enforced. A state event whose
state_keystarts with@must have that key match the sender. Without it any member holdingstate_defaultcould write state keyed to another user's MXID, which clients routinely read as authored by that user. - Local event authorization no longer fails open. In
room_service.cppthe authorization call sat inside three nested guards with no else branch, so a parse failure, an unrecognised room version, or a room whosem.room.createwas absent from local state all fell through to composing and returning the event unauthorized. The denial for a missing create event already existed as rule 2 insideauthorize_event_against_auth_events; it was simply never reached. Every path is now fail-closed. The federation ingest path and state resolution always called the check unconditionally and were unaffected. - Canonical JSON duplicate-key detection is O(n) instead of O(n²). Both the
parser's per-key
std::ranges::any_ofscan and the serializer's nestedobject_has_duplicate_keysloop were quadratic in an object's member count, bounded only by the 1 MiB body cap — roughly 1.1×10¹⁰ string comparisons for a body of ~150,000 unique keys, on unauthenticated endpoints such as/loginand/register. Both now use a hash set, and the parser enforces an explicitmax_object_memberscap of 65536 as defence in depth. - The master key is no longer read from disk twice per authenticated request.
lookup_token_hashesderives both the v3 and v4 access-token HMAC keys, and each derivation opened and read the operator's master key file — so every authenticated request performed two blocking reads of the server's root secret and twosodium_mlock/munlockcycles on a 4 KiB buffer. Under concurrency that mlock churn could exhaustRLIMIT_MEMLOCK, at which pointSecretBuffersilently falls back to swappable memory for the key every other key is derived from. Both keys are now derived once and cached, invalidated on the file's (path, size, mtime) identity so replacing the key still takes effect without a restart — the steady-state cost is onestatinstead of two full reads. - A failed
mlockof the master key is now reported.SecretBufferalready recorded it and exposedis_locked(); nothing ever called it, so a server holding its root secret in swappable memory said nothing at any log level. It now warns once per process, naming the remedy. Deliberately not fatal: refusing to start would turn a hardening shortfall into an outage. - Media responses carry the spec's content-security headers. Client-facing
download and thumbnail responses set only
Content-Typeand CORS headers —Content-Security-Policyappeared nowhere in the tree, andContent-Dispositiononly on the federation multipart body. All three of CSP,Cross-Origin-Resource-Policy: cross-originandContent-Dispositionare now set, withinlineused only for the content types the spec's "Serving inline content" allowlist permits andattachmentfor everything else. Nofilenameparameter is emitted yet — this server does not persist the upload filename, which the spec permits and which is now tracked as a gap. /loginis throttled per account, not only per source IP. The rate limiter's per-user tier keys on the authenticated user, which pre-login is nobody, so guesses against one account spread across many source IPs accumulated against nothing. Failures are now counted against the claimed user ID — whether or not it exists, so the throttle cannot be used to probe which accounts are real — with a lockout after five failures in fifteen minutes, cleared by any successful login.POST /_matrix/client/v3/account/deactivateis implemented. It previously had no handler at all; the path appeared only as a literal in the suspended-user allowlist, so a user whose credentials were compromised could not close their own account — only an admin could apply a reversible lock/suspend. The endpoint now performsm.login.passwordUIA, marks the account permanently deactivated (schema version 14 adds adeactivatedcolumn tousers), revokes every access and refresh token including the caller's own, and replaces the password hash with an unmatchable value. The users row is retained so the localpart is never reissued. Every identity-server-bound 3PID the account holds is unbound as part of deactivation, reusing the storedid_server/client_secret/sidthrough the same trusted-base-URL SSRF gate asPOST /account/3pid/unbind;id_server_unbind_resultissuccessonly when every identifier was unbound,no-supportwhen any could not be. Unlike the standalone unbind endpoint, a failure there does not fail the deactivation — it is irreversible and leaves no owner to retry with.membership_acceptorandinvite_handlertake the runtime lock. Both mutatedstore.rooms/state/eventsand the stream-ordering counters with no lock held, where their siblingingest_pdu_eventlocks correctly. Not a live race in any shippable configuration — the worker-relay path already held the global mutex and is always constructed when federation is enabled — but a trap for the first change that made the worker optional. They now take the global recursive mutex only: acquiring a room stripe there would invert the documented stripe-then-global order againstingest_pdu_event.- The dead X-Matrix/TLS peer cross-check was removed.
SignedFederationRequest::tls_peer_server_namewas assigned nowhere in production, only in four conformance tests, so its!empty()guard could never fire while its comment and those passing tests asserted a defence-in-depth control that did not exist at runtime. Inbound federation TLS is one-way — the originating server presents no client certificate and SNI carries our own hostname — so there is no legitimate peer name to compare. X-Matrix Ed25519 verification remains the actual, fail-closed gate and is untouched. - Sliding-sync connection state is now bounded.
HomeserverRuntime::sliding_sync_connectionsis keyed on a client-chosenconn_idand was only ever erased on theM_UNKNOWN_POSpath, so a client varyingconn_idgrew it until the process was OOM-killed.sliding_sync.hppdocumented a one-hour idle eviction andlast_usedwas written on every request — but never read, and no sweep existed. Idle connections are now evicted after that hour, and each user/device is capped at eight with least-recently-used eviction. - Review round (PR #488). Codex review found nineteen issues on the branch above; the ones that changed behaviour are recorded here rather than folded silently into the entries they correct.
- The key-resolution negative cache was keyed on the origin alone, so naming a real server with a bogus key ID poisoned that server's own traffic for the full TTL — a denial of service against a third party, worse than the amplification the budget was added to bound. Now scoped to (origin, key ID).
- The budget keyed on the direct TCP peer, which behind the reverse-proxy
deployment the example config describes is
127.0.0.1for every remote server. Trusted-proxy resolution is now a shared helper used by both the client-server limiter and the federation budget. - A resolution the key cache can serve does no network work and is no longer charged, so a peer signing with a second published key is not throttled.
- Rule 9 validation was written against room version 11 and applied to all
versions. Versions 1-9 permit a string-encoded integer power level, so
integer-only validation rejected valid historical events;
notificationsjoined the rules only in v6; and rule 1.4 tests for the presence ofcreator, not its type. All three are now version-aware, and string-encoded levels are read as their value rather than silently defaulted. - Deactivation discarded both token-revocation results and still returned 200.
A failed refresh-token update was directly exploitable, because
refresh_local_sessionchecked only that the user existed. Revocation failure now fails the request, and a deactivated user cannot refresh at all — two independent gates rather than one. - Deactivation ignored the request's
id_server, so a client selecting a trusted identity server got a result implying its choice was honoured. - A throttled login returned 429 with
M_FORBIDDENand no delay; it is nowM_LIMIT_EXCEEDEDcarryingretry_after_ms. - The sliding-sync connection key joined user, device and connection ID with
/, which a device ID may contain, so deviceA/B's state was counted and evicted under deviceA. Components are now length-prefixed. Pruning also evicted a connection to make room that an existing-key poll did not need. - The master-key cache identity was (path, size, mtime), which
cp -pand reproducible deployment tooling preserve; it now uses inode, device and ctime, which an in-place overwrite cannot leave unchanged. object_has_duplicate_keyskept itsnoexceptafter gaining an allocation, turningbad_allocintostd::terminate.KeyResolutionSlotheld its runtime by raw pointer.scripts/reject-unsafe.shno longer flags comments as libsodium violations. Its comment-exclusion filter,grep -vE '^[[:space:]]*//', ran aftergrep --line-numberhad already prefixed every hit withpath:line:, so the anchor could never match and the filter excluded nothing. Any comment that merely named a libsodium symbol failed the pre-commit gate. A gate that reports violations where there are none is one people learn to bypass, which is the more serious failure. The anchor now accounts for the prefix; the rule itself is unchanged and still rejects real calls.- Pre-authentication remote-key resolution is now budgeted.
resolve_inbound_remotecalls the remote-key resolver beforecheck_inbound_request_signature— necessarily, since verifying a signature requires the key being resolved — so under the shippedsecurity.federation.default_policy=allowan unauthenticated sender could name any origin in anX-Matrixheader and make this server perform.well-known+ SRV + DNS discovery plus an outboundGET /_matrix/key/v2/serveragainst a host of their choosing. That is load here and a reflection vector at whoever was named, andCachedServerDiscoverygave no protection because it is keyed on the very field the attacker varies. Bounded now bykey_resolution_per_ip_rate(10/60s),key_resolution_max_in_flight(8, rejecting rather than queuing over the cap) andkey_resolution_failure_ttl(300s negative cache) — keyed on source IP, because the origin is attacker-chosen. Both pre-authentication containers are capped with FIFO eviction; an unbounded pre-auth map would have been the same class of bug this fixes. A throttled resolution logskey_resolution.throttledat warning with the origin and the budget that denied it, because the failure this risks introducing — a new federation partner intermittently failing to join — is otherwise very hard to diagnose.default_policy=denywith a populatedallowed_serverslist already closed this independently. config/merovingian.conf.examplewas missing 21 of the 109 configuration keys the parser accepts, plus two whole prefix families. All 109 keys were documented in the user manual, so this was an example-file gap rather than an undocumented-feature one — but an operator working from the example had no sign that SSO, OIDC discovery, Application Services, the PostgreSQL role separation added in 0.12.3, the per-origin federation budgets, or the rate-limit tiers existed at all. Added with the reasoning for each, and the file re-verified against the parser:--dry-runreports "Configuration validation passed" with zero findings, and an unknown key is still rejected, so the check is meaningful rather than vacuous.migrations/AGENTS.mdcarried two contradictory "Current highest" lines (012and013) — the 013 branch added a line instead of updating the existing one. Now a single line, reading014.
0.12.3¶
- PostgreSQL privilege separation is applied rather than only provisioned.
packaging/postgresql/provision-roles.sqlcreated a DDL migration role and a DML-only runtime role, andset_postgresql_role/reset_postgresql_roleexisted and were covered by an integration scenario — but nothing called them. A deployment that had provisioned the roles still served every request with its login role's full privileges, and testing the functions in isolation could never have caught that.open_postgresql_persistent_storenow assumes the migration role for the DDL phase, drops to the runtime role to serve (whether or not a migration ran, so a steady-state restart is equally constrained), and refuses to open when a configured role cannot be assumed — continuing would serve with wider privileges than asked for, silently. Configured viadatabase.migration_role/database.runtime_role; both empty issues no SET ROLE, leaving existing single-role deployments unaffected. - PostgreSQL role changes are now correctly classified as restart-required.
build_reload_planreporteddatabase.migration_role/database.runtime_rolechanges, but derived their policy fromreload_policy_for_key, which did not list either key and so fell through to thereloadabledefault. An operator editing the separation and reloading would be told the change had applied while every open connection kept its old role, since roles are assumed withSET ROLEat connect time — the exact silent mismatch the surrounding code comment claimed to prevent. Found by investigating low patch coverage on the change: the line was untested and wrong. database.migration_role/database.runtime_roledocumented in the user manual's configuration reference and reload-policy table, where they had been absent since being introduced.- Email pushers are refused instead of stored and discarded.
kind: "email"was accepted, persisted, listed back byGET /pushers, then skipped at delivery time, so the user had positive confirmation of notifications that would never arrive. Now400 M_INVALID_PARAMnaming the reason. - The "no tested upgrade path" blocker was withdrawn as an error. Both
backends already migrate on open via
apply_pending_migrations, each step in its own transaction, and the path is exercised end to end. It had been added to the charter after readinginitialize_current_schema's empty-database guard and concluding no migration existed, without reading the following lines where it does.
0.12.2¶
-
docs/todos/production-milestone.mdcorrected after the 0.12.1 release. The SSO login row still read "m.login.ssonot advertised;GET /_matrix/client/v3/login/sso/redirectunrouted" after that work had shipped — the row was never updated when the branch merged, so the document that decides whether the project can be released understated its own readiness. Rows creditingfeature/release-blockersnow cite 0.12.1, since the branch is merged and gone, and the closing summary lists the rows that are genuinely still open rather than excluding only HTTP keep-alive. -
leave_roomreleasesruntime.mutexthrough a scoped guard. The remote leave exchange released the lock by hand and re-acquired it at nine separate return paths; a throw anywhere across the three federation round trips left the mutex down for that thread and the next request it served. The exchange now sits in oneScopedGuardReleasescope, deleting all nine manual re-locks, with thesend_leaveresult declared outside it because the membership write that consumes it must hold the lock. -
join_room/leave_roomwere recorded in 0.12.1 as sharingcreate_room's lock gap; they do not. Both already released the guard around their federation calls. The regression written to expose the supposed stall passes against the unfixed code and is kept as a guard on the property. -
An appservice's
sender_localpartuser is now checked against other registrations' exclusive namespaces. That user is created at startup, so an exclusive-namespace collision over it is guaranteed rather than latent — yet nothing detected it.load_registrations()/validate_registrations()now take the homeserver'sserver_name(defaulted empty, which skips only this check) so the sender user id can be built and matched. The check runs both directions per pair and never self-compares: a registration's own sender legitimately sits inside its own exclusive namespace, and reporting that would refuse to start every correctly configured bridge. -
docs/todos/production-milestone.mdreassessed and restructured. The charter conflated "we must know this" with "CI must prove it every pull request", and carried one item — "complete full Matrix v1.19 conformance and endpoint coverage" — that could never be marked done and so blocked 1.0.0 permanently while communicating nothing. Soak, load and chaos runs cannot produce meaningful numbers on shared, time-limited runners: thresholds loose enough to survive runner noise prove nothing, tight enough to mean something flake, and a green soak badge that means nothing invites the conclusion that concurrency has been checked. They are now maintainer-run release evidence recorded at tag time beside the compiler flags and checksums, which is stronger evidence than a CI figure because the hardware is real. Five blockers remain:/messageschunk-relevant state, PostgreSQL privilege separation that is provisioned but never applied, an external Complement run, push silently discarding email pushers, and — newly identified — no tested upgrade path from a database written by an older release. Descoped items are recorded under "Decided against" rather than deleted, so a later audit does not silently reintroduce them. -
GET /messageshonourslazy_load_members. The endpoint returned the room's full current state, including every member, defeating the flag exactly where it costs most — pagination repeats that payload for every page. Two causes:lazy_load_memberswas never parsed anywhere in the codebase, and/messagespassed a default-constructed filter to its state builder, so no state filtering happened at all.RoomEventFilternow carrieslazy_load_membersandinclude_redundant_members, the request's own filter reaches the builder, and membership events are restricted to senders appearing in the chunk. Non-member state is untouched — it is relevant to rendering the chunk regardless.
The two endpoints' state fields are NOT the same contract: /context
returns "the state of the room at the last event returned" and reconstructs
it temporally, whereas /messages returns "state events relevant to showing
the chunk". Copying /context's temporal walk would have been wrong.
0.12.1¶
Release-blocker closures branch (feature/release-blockers). The audit that
defines this branch is recorded in
docs/todos/production-milestone.md
"Release-blocking functional holes" — eight functional holes plus the still-open
production gates. This section is updated as each closure lands.
- Documented the release-blocking audit: the functional-hole table in
docs/todos/production-milestone.md, and a new "Runtime concurrency" ledger row indocs/todos/capability-gaps.mdrecording thatHomeserverRuntime::mutexserialises every client-server request and inbound federation transaction, and that every HTTP response carriesConnection: close. - HTTP/1.1 persistent connections (keep-alive, RFC 9112 §9.3). Connections
are now served as a sequential loop of request rounds instead of
one-request-per-connection: HTTP/1.1 clients get persistent connections by
default,
Connection: closeis honoured and echoed, and HTTP/1.0 clients are kept alive only on an explicitConnection: keep-alive. Each request's body is drained exactly (Content-Length bytes) before the connection waits for the next request, and surplus bytes are carried into the next round, so request boundaries are never lost; pipelining itself stays out of scope — pipelined requests are buffered and answered strictly in order. Kept-alive responses carryConnection: keep-aliveplus an advisoryKeep-Alive: timeout=Nhint. Security composition: the connection guard is now phase-aware (http::connection_should_close) — an idle parked connection is bounded only by the operator-tunable idle window (server.http.keep_alive_idle_seconds, default 15 s, 1..300, restart required) and is never killed as "slow", while the full slowloris policy applies unchanged once request bytes arrive; a process-wide CAS cap (server.http.keep_alive_max_connections, default 8, 1..4096) bounds how many connections may be parked waiting for a next request, so a client cannot park a main-pool worker thread per connection beyond the operator's budget./synclong-polls still run on the dedicated sync pool, and a kept-alive long-poll connection is handed back to the main pool for its next round, preserving pool separation. New unit tests (tests/unit/test_http_keep_alive.cpp) cover the framing decision, header token parsing, policy validation, and the idle-vs-slow guard phases; new integration scenarios (tests/integration/test_http_server_listener_flow.cpp) exercise two sequential requests over one real connection (plain and TLS), body-drain framing across a pipelined send,Connection: closehonouring, idle-window expiry, and the parked-connection cap. - Production-grade rate limiting (tiered policies). Every client-server
route is now classified into one of six explicit tiers in
http::rate_limit_tier_for()(src/http/rate_limit.cpp) — one greppable prefix table:auth_sensitive(/login, /register, /refresh and the */requestToken family, 20/min),media(20/min),sync(90/min),federationroutes on the client listener (120/min),admin(30/min) andgeneric(90/min via the operator-tunableclient_rate_limits.default_per_ip). Per-IP resolution is most-specific-first: an operator per-prefix override (client_rate_limits.per_ip.*) beats an operator tier override (client_rate_limits.tier.<name>, validated against the tier table so a typo is a parse finding), which beats a built-in per-endpoint refinement (keys/devices 30/min, search 20/min), which beats the tier default. Unauthenticated routes are bucketed per remote IP — the credential/enumeration surface now gets the tighterauth_sensitivecap instead of the generic fallback. A misconfigured entry at any level resolves to "invalid_policy" and the request is denied, so a bad operator value can never silently mean "no limit" (fail-closed, issue #412). Inbound federation gains a per-origin cap on requests OUTSIDE /send (query, backfill, membership, key and state endpoints) — keyed by the X-Matrix verified origin, tunable viasecurity.federation.per_origin_request_rate(default 600/min); /send keeps its weighted transaction/PDU/EDU trio so a transaction and its contents are never double-counted. Tier keys arerestart_requiredin the config-reload plan, matching the rest of the rate-limit block. New unit scenarios intests/unit/test_http_rate_limit_tiers.cppcover the classification table, tier-name round-trip, tier defaults, override precedence and the fail-closed path; new conformance scenario asserts the 429 error shape; new integration scenario serves a 429 mid-pipeline on a kept-alive connection and recovery after the window.
Adds SSO login (m.login.sso) per Matrix v1.19 CS API §"Client login via SSO":
homeserver-side routing, redirect validation, and the m.login.token exchange.
Merovingian does not itself speak an external SSO protocol (CAS/SAML/OIDC) —
this closes the homeserver-side half of the flow so an operator's own external
IdP adapter has a real integration point to call into.
m.login.ssois now advertised byGET /_matrix/client/v3/login, including the configuredidentity_providerslist, but only whenserver.sso.*is fully configured (homeserver::sso_is_configured) — otherwise the flow is silently omitted rather than half-advertised.GET /_matrix/client/v3/login/sso/redirect[/{idpId}]is now routed. Validates the client-suppliedredirectUrlagainst an operator-configured HTTPS allowlist (server.sso.redirect_url_allowlist) before ever building a redirect — this is the control that prevents the endpoint from being an open redirect (Matrix v1.19's own security-considerations section for this endpoint documents the exact attack it closes). ValidatesidpIdagainstidentity_providers, returning404 M_NOT_FOUNDfor an unrecognised IdP per spec. On success, 302-redirects to the operator-configuredserver.sso.authorization_url.m.login.tokenis now implemented inPOST /_matrix/client/v3/login(previously entirely absent — onlym.login.passwordwas handled).homeserver::complete_sso_loginis the integration point an operator's external SSO adapter calls once it has authenticated the user and mapped them to a local Matrix user id: it re-validatesredirectUrl, mints a short-lived (~30s), single-use login token using the same keyed-hash machinery access tokens use, and returns the client'sredirectUrlwith any staleloginTokenparameters stripped and the new one appended, per spec.homeserver::redeem_login_tokenconsumes the token exactly once (atomic redeem, backend-first) and hands off tohomeserver::login_local_user_by_id— the same account lock/suspend gate and session-issuance path password login uses, factored out oflogin_local_userso SSO login cannot bypass it.- New durable
login_tokenstable (schema version 12, migration012_login_tokens.sql), structurally disjoint fromaccess_tokens— the same token-confusion mitigation already applied toopenid_tokens— so a login token can never authenticate an ordinary client-server request. -
New config surface:
server.sso.enabled,server.sso. authorization_url,server.sso.redirect_url_allowlist, andserver.sso.identity_providers.<idpId>.{name,icon,brand}. Parse-time validation fails closed:enabled=truewith a missingauthorization_urlor an emptyredirect_url_allowlistis rejected outright, as is a duplicate/empty identity-provider id, an empty name, or a non-mxc://icon. Begins closing the Application Service API gap (Matrix v1.19): the entire surface was previously unimplemented (seedocs/todos/capability-gaps.md). This entry covers the registration/auth layer and outbound transaction delivery; the outbound query hooks and/thirdparty/*remain and land in a follow-up entry under this same section. -
Appservice registration files. New
merovingian::appservicemodule (include/merovingian/appservice/,src/appservice/) parses registration documents (as_token,hs_token,id,url,sender_localpart,namespaces.{users,aliases,rooms}with per-entryexclusive/regex) and builds an in-memoryAppserviceRegistryat startup from the newappservice.registration_filesconfig key (config::AppserviceConfig, restart-required). Registration files are parsed as JSON — a strict subset of the YAML the spec describes — rather than pulling in a new YAML dependency; see the doc comment onAppserviceRegistrationfor the rationale.as_token/hs_tokenare held incore::SecretBufferand compared withcrypto::constant_time_equal_variable_length, never==. Duplicateid/as_tokenacross registrations is rejected at load time (spec MUST) and drops the whole set rather than guessing a winner. - as_token bearer auth +
?user_id=/?device_id=masquerade. A client-server request bearing a registered appservice'sas_tokennow authenticates as that appservice'ssender_localpartuser by default, or as the user named by?user_id=when it falls within the appservice'susersnamespace (403 otherwise);?device_id=is honoured when it names a device already known to belong to that user (400M_UNKNOWN_DEVICEotherwise). Resolved once per request inhandle_client_server_request_implvia an internal, never-on-the-wire token substitution (appservice/masquerade_token.hpp) so every existingauthenticated_user/authenticated_session/authenticated_admin_usercall site, and every deeper handler that re-derives identity from a bareaccess_token, works unmodified for both ordinary sessions and appservice masquerades. A raw incoming token already in the internal reserved shape is rejected before any auth logic runs, closing the obvious forgery path. m.login.application_service.POST /registerandPOST /loginaccepttype: m.login.application_service(bearer-authenticated withas_token), bypassing the ordinary registration flow entirely per spec ("Server admin style permissions") — no registration token, no UIA. New passwordless account creation (register_appservice_user) and passwordless login (login_appservice_user) inauth_service.cpp, sharing the existing session/token-issuance tail with the password path via a newcomplete_loginhelper.GET /loginnow advertisesm.login.application_servicealongsidem.login.password.- Namespace exclusivity. An appservice's
exclusivenamespace now blocks entity creation by anyone else: ordinaryPOST /registerandm.login.application_serviceregistration both reject a username inside another appservice's exclusiveusersnamespace withM_EXCLUSIVE, andPUT /_matrix/client/v3/directory/room/{roomAlias}andPOST /createRoom(room_alias_name) both reject an alias inside an exclusivealiasesnamespace the same way — except for the owning appservice itself. - Outbound
PUT /_matrix/app/v1/transactions/{txnId}delivery. NewAppserviceClient(src/appservice/appservice_client.cpp) PUTs one event per transaction, authenticated withAuthorization: Bearer <hs_token>. Wired synchronously intoroom_service.cpp'sdispatch_appservice_deliveryfromsend_event(), local membership transitions, and the federation-accepted-PDU path, for every registered appservice whose namespaces match the event (registration_interested_in_room_event). The delivery cursor (next_txn_id,delivered_stream_ordering, and the currently in-flightpending_txn_id/pending_stream_ordering) is durable in the newappservice_txn_cursortable (migration013_appservice_txn_cursor.sql, schema version 13), so a retried transaction reuses the exact same txn_id and event after a restart, per spec ("Homeservers MUST NOT alter ... events they were going to send within that transaction ID on retries"). Appservice URLs are operator-configured (registration files), not attacker-influenced, sohttp::OutboundRequestgained an opt-inallow_cleartext_httpescape hatch from its https-only invariant (defaultfalse, unchanged for every other caller) — the spec's own canonical registration example, and most real-world bridges, use plainhttp://127.0.0.1:.... -
Known gaps, tracked in
docs/todos/capability-gaps.md: the outbound transaction delivery above sends one event per transaction synchronously on the request path (no background dispatch, no retry/backoff timer independent of new events, noephemeraldata); the outboundGET /_matrix/app/v1/users/{userId}//rooms/{roomAlias}query hooks are scaffolded (client methods exist, unit-tested) but not invoked from any local-miss call site; the/_matrix/app/v1/ping↔POST /_matrix/client/v1/appservice/{id}/pinground trip and/_matrix/client/v3/thirdparty/*remain unimplemented. -
Application service registration files are now parsed as YAML. The module parsed JSON only, reasoning that JSON is a subset of YAML 1.2. That is true and was not sufficient: the spec's own registration example and every shipped bridge use block-style
registration.yaml, so the homeserver could not load a single real registration file.registration_yaml.cppis a strict bounded subset — 2-space block maps and lists, plain and quoted scalars,#comments,null/~— where anchors, aliases, tags, block scalars, tabs, document markers and unknown keys are parse failures rather than silent coercion, with fail-closed input bounds (256 KiB file, 4 KiB line, 64 namespace entries, 32 protocols).load_registration_file()tries YAML first and falls back to JSON, so anything that loaded before still loads. Covered by a test that parses the spec's example verbatim, trailing comment and emptyrooms: []included. - An empty namespace list written as
rooms: []is accepted. The YAML parser required every namespace key to carry an empty value with an indented list beneath it, which rejected the spec's canonical example. Empty flow sequences are now accepted — they carry no entries, so supporting them costs nothing — while every other inline value, including non-empty flow sequences the bounded subset cannot parse, is still rejected rather than silently dropping entries. - Namespace regexes are anchored to whole identifiers (security).
namespace_matches()usedstd::regex_search, which is unanchored. A namespace regex is a claim over an identifier, not a substring: the spec's own example pattern@_irc_.*therefore also matched@evil@_irc_bob:example.org, so one appservice's exclusive namespace could claim unrelated local users and block their registration. Nowstd::regex_match, with coverage for the genuine match, the substring escape, and a shared-prefix non-match. src/appservice/AGENTS.mdadded, recording the YAML-subset rules, the anchored-matching requirement, the token-handling rules, and one known gap: overlapping exclusive namespaces between different appservices are not detected, so two registrations may both exclusively claim the same pattern.-
Third-party lookups implemented, closing the last functional gap in the Application Service API. All six client-server routes —
GET /_matrix/ client/v3/thirdparty/{protocols, protocol/{protocol}, location, location/{protocol}, user, user/{protocol}}— are now backed by the corresponding outboundGET /_matrix/app/v1/thirdparty/*calls (AppserviceClient::query_thirdparty_*, new inappservice_client.cpp). A protocol name is owned by whichever registration lists it inprotocols:; an unrecognised protocol is404 M_NOT_FOUNDwith no outbound call. The alias/userid lookups (location/userwith no protocol) have no such ownership signal, so they fan out to every registered appservice and aggregate the results. An appservice that is unreachable, times out, or answers with a non-200/malformed body contributes nothing rather than failing the whole client request — only when no appservice contributed anything does the route 404 (or, for/protocols, return{}, matching the previous placeholder's behaviour for the no-appservice case so Element does not resume its retry loop).instance_idis minted by the homeserver per spec, never trusted from the appservice reply. Every field taken from an appservice's JSON reply is type-checked and bounded (dropped malformed array entries, non-stringfieldsmembers dropped, capped array/object sizes and string lengths) before being echoed to a client — a bridge is not a trusted peer. Outbound calls carry thehs_tokenas a Bearer credential exactly like the existing transaction/query calls. All six routes bypass client-side rate limiting and require authentication, matching the spec's per-endpointRate-limited: No/Requires authentication: Yesflags exactly — the previous/protocolsplaceholder was incorrectly rate-limited. New unit tests (tests/unit/test_appservice_client.cpp, tag[thirdparty]) cover the disabled (url: null) fast path and the bounded/defensive response parsing in isolation; new conformance tests (tests/conformance/test_appservice_thirdparty_conformance.cpp) cover the documented status codes, the auth/rate-limit flags, and the no-appservice-registered cases; new integration tests (tests/integration/test_appservice_thirdparty_flow.cpp) drive the full path against a real local mock appservice, including multi-appservice aggregation and the unreachable-appservice degrade-not-fail guarantee. -
resolve_policy_server_hookno longer holdsHomeserverRuntime::mutexacross thetrust_safety.policy_server_urlround trip.handle_federation_http_requestwas already fixed for this (#415), butregister_local_user,create_room(and room upgrade), and the media download/thumbnail policy check all called the same function directly while still holding the lock — an operator running withtrust_safety.enabledinherited a policy server that, if slow or unreachable, froze registration, room creation, and media reads for every other user for up topolicy_server_timeout. The fix wraps the remainder ofresolve_policy_server_hook(the injectable test hook and the realOutboundClient::performcall alike) in oneNetworkIoUnlockscope, so every call site is fixed at the source. New regression coverage intests/integration/test_request_lock_contention_flow.cpp(registration, room creation, media download, each gated on a blocking policy-server hook while an unrelated request is asserted to complete promptly). create_roomwas silently double-lockingHomeserverRuntime::mutex, which madeNetworkIoUnlocka no-op for anything blocking inside it — found by the new regression coverage above, not by inspection.HomeserverRuntime::mutexis astd::recursive_mutex, andcreate_roomtakes its own lock on it (it must stay independently callable outside a request handler — seelocal_smoke_flow.cpp). Called fromclient_server.cpp, which already held its own outer guard for the whole request, this recursively re-locked the same mutex a second time — legal for arecursive_mutex, and therefore silent.NetworkIoUnlock's singleunlock()call only undoes one level of that recursion: it released the outer guard (the oneRequestLockScopehad published) whilecreate_room's own, inner guard — the one actually in scope at the point of the network call — stayed locked. The mutex was therefore never really available to another thread during the (now-unlocked-in-theory) policy-server round trip. This means theNetworkIoUnlockmechanism introduced in 0.11.13 was incomplete for any call chain that passed through a self-locking function: it correctly modelled "the caller holds one lock, release it," but not "the caller holds one lock, a callee recursively takes a second one, release the one actually in scope." The room-creation regression test added alongside theresolve_policy_server_hookfix above caught this immediately — it deadlocked rather than passing, exactly as a lock bug should. Fixed by publishingRequestLockScopearoundcreate_room's own guard (room_service.cpp) and releasing the caller's outer guard before delegating to it at all three call sites (client_server.cpp×2,local_http_router.cpp×1) — the unlock/call/relock idiom already used forjoin_room— socreate_room's own guard becomes the only lock in scope and is the oneNetworkIoUnlockactually finds and releases.join_room/leave_roomshare the identical self-locking shape and very likely have the same latent gap for their own outbound federation calls; fixing them is tracked as separate follow-up work, deliberately out of scope here to avoid last-minute scope creep on a lock-correctness change.- New load/soak evidence harness for the global runtime lock
(
tests/integration/test_runtime_lock_soak_flow.cpp, opt-in behind thebuild_load_testsMeson option — a manual tool with no CI wiring, matching the existingbuild_live_testsprecedent). Drives concurrent/synclong-polls, ordinary reads, message sends, and signed X-Matrix-authenticated inbound federation transactions — all over real sockets with HTTP/1.1 keep-alive — and reports throughput and p50/p95/p99 latency per category, so the "Global runtime lock" release blocker indocs/todos/production-milestone.mdcan be closed (or kept open) on measurement rather than intuition. **Measured, 20 s runs, 6 simulated users -
2 simulated federation peers, before (pre-fix source reverted to a scratch copy) vs. after this change**: reads ~385→374 req/s (p50 7.2→7.4 ms, p95 25.0→26.4 ms), sends ~478→469 req/s (p50 6.7→7.0 ms, p95 21.4→21.8 ms), federation transactions ~208→204 req/s (p50 6.4→6.7 ms, p95 22.6→23.0 ms, capped by the federation per-origin transaction rate limiter, not lock contention) — statistically indistinguishable, as expected: this change's two fixes (below) do not touch that hot path when
trust_safetyis disabled. The direct evidence for those two fixes is instead the regression tests themselves: before, the affected request deadlocks (unbounded — confirmed by direct reproduction under a bounded external timeout); after,--durations yesshows registration/room-creation/media scenarios completing in 66–132 microseconds. Seedocs/http-transport.md, "Load/soak evidence", for how to run it and the full measured results. -
Appservice user and room-alias query hooks are wired.
query_user()andquery_room_alias()were implemented but called from nowhere, so a bridge that materialises users and rooms on demand got a flat 404 for exactly the identifiers its namespace exists to claim.GET /directory/room/{roomAlias}andGET /profile/{userId}now consult the appservice whose namespace covers the identifier and re-check locally afterwards. Only the owning appservice is queried — fanning out would leak which aliases and users clients look up — and an unreachable or declining appservice is a miss, not an error, so a bridge outage cannot turn a 404 into a 502. - Exclusive namespaces claimed by two appservices are now boot-time
findings. Nothing enforced the spec's exclusivity guarantee across
registrations: two could both exclusively claim
@_bridge_.*and whichever the registry consulted first silently won. Detection compares pattern strings and is deliberately conservative — differing regexes with overlapping matches are not reported, because a false conflict that refuses to start a correct deployment would be worse than the gap. - The request lock is restored on every path out of a released region. The
dispatcher's
guard.unlock(); f(); guard.lock();triples never re-acquired iff()threw, leaving the request — and the next one on that thread — running with the locking invariant broken. AddsScopedGuardReleaseand converts 8 of 21 sites. Three publicRooms/alias federation-proxy paths were worse: they released the guard and returned throughdispatch_err/dispatch_respwithout ever re-acquiring, readingrt.cors— which config hot-reload can replace — unsynchronised. - Federation worker hardening no longer fails open on non-Linux platforms.
apply_worker_hardening()(src/platform/runtime_hardening.cpp) unconditionally returnedaccept()on every non-Linux build regardless offederation.worker.apply_hardening(defaulttrue), so the worker logged "runtime hardening applied (seccomp filter active)" and handled untrusted federation traffic completely unsandboxed on FreeBSD/OpenBSD/NetBSD, with zero test coverage anywhere in the suite. It now returns a fail-closed rejection (worker_hardening_unavailable_decision(), exposed for testing) naming the unavailable controls and theapply_hardening=falseescape hatch, so the worker process refuses to start rather than run unsandboxed; theWorkerSupervisorsees a crash-looping child and federation degrades to 503 instead of ever accepting unsandboxed traffic. The main process's ownhardening_self_check.cpp/is_ready()gate was already correctly fail-closed (anyunknownordisabledcontrol blocks startup) — this was specifically a gap in the worker's separate hardening path.MEROVINGIAN_TEST_DISABLE_HARDENINGis untouched by this change and continues to let the in-process test suite skip hardening in the shared Catch2 process. New unit coverage intests/unit/test_runtime_hardening.cpp([worker]) proves the fail-closed decision on every platform without installing a real sandbox on the test process, plus a non-Linux-only scenario exercisingapply_worker_hardening()itself. - Real listener coverage in CI. Audited the only test that spawns the real
merovingian-serverbinary (tests/integration/test_server_startup_hardening_flow.cpp) and found it proved only "reaches the listening state and shuts down cleanly", never that the server actually serves a request — and that every existing CI job WARN-skipped before reaching even that, because the runtime hardening self-check can only reportready=trueon a hardened-profile build (debug builds never advertise_FORTIFY_SOURCE) run by a process holdingCAP_SETPCAP(root containers fail the privilege-drop check instead), a combination no existing job satisfied. Added a real HTTP GET over a real TCP socket against the spawned process's client listener (asserting a genuine 200 from/_matrix/client/versions) plus a post-shutdown connect-refused check to that scenario, and a newubuntu-hardened-listener-coverageCI job (.github/workflows/ci.yml) that builds thehardenedprofile and grants the built server binaryCAP_SETPCAPviasudo setcap cap_setpcap+epso the scenario actually reaches the live-serving assertions instead of skipping. - Build-time link-time hardening enforcement. Added
scripts/check-elf-hardening.sh, which statically inspects a built binary's ELF headers (PIE,PT_GNU_RELRO,DT_BIND_NOW, non-executablePT_GNU_STACK) and fails closed if any is missing — a CI-time counterpart to the runtime ELF probe that needs no live process, so it runs identically whether the job is root, non-root, or containerized. Wired into the newubuntu-hardened-listener-coverageCI job against every shipped binary (server, federation worker, thumbnail worker, db-migrate). - PostgreSQL role-provisioning script for deployments. Audited the
"enforce runtime/migration PostgreSQL role grants" gate: the CI-level test
coverage (
postgres-integration.yml,test_postgresql_persistence_flow.cpp) proving transaction rollback, migration ordering, and runtime-role DDL denial against a real temporary PostgreSQL database was already closed;packaging/provisioned none of it for a real deployment. Addedpackaging/postgresql/provision-roles.sql, the same migration/runtime-role grant pattern CI already proves, for operators to run. Documented the remaining gap precisely indocs/database-persistence.md"Next starting points": nothing in the live startup path callsset_postgresql_role()yet —merovingian-db-migratenever opens a database connection (it only prints an offline plan) andbootstrap_local_database()always applies migrations automatically through the same connection/role that then serves runtime traffic. - Release evidence recorded in release notes. Added
scripts/collect-release-evidence.sh, run per-platform in.github/workflows/release.yml, which records the actual compiler version, confirms link-time hardening viacheck-elf-hardening.sh, lists pinned dependency versions fromsubprojects/*.wrap, summarizes the test log's Ok/Fail/Timeout counts, and lists the mandatory fuzz target names.publish-alpha-releasenow folds every platform's evidence plus a package checksums section into the published release notes, alongside the existing GPG signatures. - Audit findings recorded, not just fixes: mandatory fuzz execution
(
.github/workflows/fuzz.ymlalready ranset -eu+-error_exitcode=77on every push/PR, nocontinue-on-error) was already closed and the capability-gaps.md row describing it as missing was stale; Complement was audited and found to mean the upstreammatrix-org/complementGo suite, not this repo's owntests/fixtures/complement/JSON-fixture flow test — a rootDockerfileexists but is not Complement-compatible and no Go/CI wiring exists anywhere, so this remains open and unstarted. Config-profile CI-naming (scripts/validate-phase1-config.shand its "Phase 1" step name) also remains open — not touched this pass.
0.11.13¶
Fixes a production stall where one slow federation peer froze the whole server, and a sync response that grew without bound.
runtime.mutexis no longer held across blocking outbound federation calls. Every client-server request and every inbound federation transaction contends on that single mutex, and two request paths were making synchronous network calls while holding it:POST /_matrix/client/v3/keys/query(one federation/user/keys/queryper remote server, each budgetedremote_timeout_seconds, default 60) and remote media download/thumbnail fetches. A peer that accepted the connection and then went quiet therefore froze the entire process for the duration of the timeout — local reads such asGET /profileandGET /media/configtook 20 seconds, and inbound/sendtransactions from unrelated servers queued for 44 seconds behind a single thumbnail fetch. Server discovery, the outbound perform, and the media redirect resolution now release the mutex for the network round trip and re-acquire it before touching runtime state again. Signing still happens under the lock, so the Ed25519 secret span is never read unsynchronised. AddedRequestLockScope/NetworkIoUnlock(homeserver/request_lock.hpp) to make the release RAII rather than hand-writtenunlock()/lock()pairs.- A user is reported at most once in
device_lists.changedandleftwere appended to once per change row, so a subject with N recorded changes appeared N times — and because an initial sync reports every change from stream position zero, that set grew for the lifetime of the account. One observed sliding sync response was 769 KB of repeated user IDs while carrying zero rooms and zero to-device messages. A new shared collector (sync/device_list_delta.hpp) collapses the change log to one entry per subject, resolves a subject that both changed and left to its most recent change type, and sorts the result so identical store state always produces identical response bytes. Applied to/sync, the MSC4186 e2ee extension, andGET /_matrix/client/v3/keys/changes. Initial syncs still report the full deduplicated set, which is what prompts a freshly logged-in device to/keys/queryits own user's devices.
0.11.12¶
Fixes a class of bug where the server's Ed25519 signing key and the thing that actually signs with it drifted apart, which on a long-running server broke every locally composed event.
- A signing key is no longer silently replaced when its published window
lapses.
valid_until_tstells federation peers when to re-fetch the key list; it was being treated as a lease on the server's own identity. Once a server's uptime exceeded the seven-day window,ensure_runtime_server_signing_keyskipped the (now "expired") key and generated a brand-new one. Two things then went wrong at once: the runtime signing provider had been built at startup from the old key and was never rebuilt, so every locally composed event failed withsigning key not held: <new key id>— clients saw403 M_FORBIDDEN "event authorization or signing failed"on everyPUT /rooms/{roomId}/send/..., including mobile clients that could otherwise sync normally — and the new key had never been published, so peers rejected requests signed with it (401onPOST /_matrix/federation/v1/user/keys/query). The key is now selected regardless of its window, and a window that is more than halfway elapsed is rolled forward and persisted instead. A key is generated only on first boot or through an explicitrotate_server_signing_key. ensure_runtime_server_signing_keynow guarantees the runtime signing provider holds the key it returns, rebuilding the provider when it does not. The preferred key is also always treated as active bycollect_active_server_signing_keys, so a window that could not be refreshed (database write failure) degrades to peers re-fetching sooner rather than to a server that cannot sign at all.GET /_matrix/key/v2/serverno longer advertises a validity the server does not honour. The publishedvalid_until_tsis now exactly the window persisted for the key, rather than an unconditional "now + 7 days" that the store never recorded.- The cached key-server document is refreshed. It was published once at
startup and served from the lock-free cache forever, so after seven days of
uptime every peer that re-fetched it saw a
valid_until_tsalready in the past. The cache entry now carries a one-hour refresh deadline; past it the fast path re-publishes before serving. reset_runtime_crypto_providerhonours its documented no-op under an external signing override. It previously replaced the federation worker's IPC-backed provider with one built from local secrets the worker does not have.- Inbound federation requests whose X-Matrix parameters are unquoted are no
longer rejected.
parse_x_matrix_authorization_headerrequired every value to be a quoted string, but the spec (Server-Server API, Request Authentication) says values "must be enclosed in quotes if they contain characters that are not allowed intokens ... if a value is a validtoken, it may or may not be enclosed in quotes". A peer sending a bare token had its whole request refused — forPUT /_matrix/federation/v1/send/{txnId}that silently discarded its PDUs behind a502 malformed federation authorization. Unquoted RFC 9110 §5.6.2 tokens are now accepted; a value carrying anything outside the tchar set is still rejected, because the sender was required to quote it. - A rejected federation Authorization header is now diagnosable. The 502
above was logged as nothing but a status code, so a peer whose traffic was
being dropped looked identical to one that never called. A
federation_proxy.authorization_unparsedwarning now records the target, the header's length, and whether it carried theX-Matrixscheme — never the header itself, which is the peer's reusable credential. GET /_matrix/client/v1/rooms/{roomId}/threadsis implemented (spec v1.4+, previously 404 — Element X calls it on every room open). Returns the room's thread roots ordered by each thread'slatest_event, most recently active first, each carrying its bundledm.threadaggregation (latest_event,count,current_user_participated).include=all|participatedis honoured and any other value is a 400M_INVALID_PARAMrather than a silent fallback toall;limitdefaults to 50, caps at 500, and rejects zero;from/next_batchpaginate on the latest-event stream ordering, withnext_batchomitted on the last page. Child events from ignored senders are excluded from the aggregation (spec MUST) and a root sent by an ignored user is returned redacted rather than omitted. Known gap recorded indocs/todos/capability-gaps.md: bundled aggregations are still produced only by this endpoint, not by/sync,/messages,/context,/relations, or/event/{eventId}.reset_runtime_crypto_providerno longer moves eachkey_idtwice, which leftruntime.database.signing_secret_keys— documented as "keyed by key_id" — holding empty ids, so every lookup by key id silently missed. Found while verifying the provider-consistency check above, which the empty ids would have reduced to an unconditional rebuild on every request.- The federation dispatch worker's signing identity follows key rotation. It
snapshotted the key when constructed and kept signing with the retired key
afterwards, which peers reject because the rotation publishes that key under
old_verify_keyswith a pastexpired_ts.rotate_server_signing_keynow hands the worker its new identity directly — notwire_federation_callbacks, which returns early once the callbacks exist and so would never have run the refresh.
0.11.11¶
Started as a documentation-only audit of the routed client-server surface
against Matrix v1.19, recorded in docs/todos/capability-gaps.md; the branch
then closed one of the gaps the audit found.
- Routed
POST /_matrix/client/v3/search(server-side event search), the last previously-unrouted endpoint the 0.11.11 audit found in the Client-Server API. Searchescontent.body(m.room.message),content.name(m.room.name), andcontent.topic(m.room.topic) — a case-insensitive substring match, not stemmed/tokenised full text search — across rooms the caller is currently joined to, never searching end-to-end encrypted rooms (spec MUST).filter(RoomEventFilter),keys,order_by(rank/recent),event_context(before_limit/after_limit/include_profile),include_state, andgroupings(room_id/sender) are all honoured rather than silently ignored.m.ignored_user_listis applied via the sharedtrust_safety::ignore_listhelper, resolved once per request. Unlike/messages, this endpoint has no per-room scope, so a newClientApiLimits::max_search_events_scanned(2000) bounds how many candidate events a single request will JSON-parse and text-match before it must hand back anext_batchcontinuation instead of scanning further;/_matrix/client/v3/searchalso gets its own 20/min-per-IP rate-limit tier (the same tier as media) rather than the generic 90/min fallback. Known gaps, recorded indocs/todos/capability-gaps.md: only rooms the caller is currently joined to are searched (the spec's fuller "including rooms you have left" scope is not implemented — the same join-only membership gate/messages//context//event/{eventId}already use);order_by: rankis only locally accurate within a request's bounded scan window, not a global top-K across the caller's entire matching history; per-groupnext_batchpagination is not implemented (spec allows this); and the MSC3765 extensible-topiccontent['m.topic']representation is not indexed. -
Routed
GET /_matrix/client/v3/rooms/{roomId}/context/{eventId}(event context for permalink resolution). Requires authentication and the same joined-membership gate as/messagesand/event/{eventId}; an unknown event id and an event id belonging to a different room both fail closed with an identical 404M_NOT_FOUNDso a member cannot tell the two cases apart.limit(spec default 10, applied to the combined size ofevents_beforeandevents_after) is clamped to the same maximum/messagesuses.filteraccepts a JSONRoomEventFilter, reusing the existingsync::EventTypeFilter/RoomFiltermachinery via a newsync::parse_room_event_filter_argument()rather than a second filter implementation; a non-empty value that fails to parse as a JSON object is rejected with 400M_BAD_JSONinstead of being silently ignored.startandendreuse the plain stream-ordering token formatGET /messagesalready produces, so a client can continue paginating from a context response withGET /messages?from=<token>&dir=<b|f>. -
Two whole spec sections were absent while going untracked, so they were invisible in our own planning: the Application Service API (no
as_token/hs_token, no/_matrix/app/v1/*outbound calls, nom.login.application_service, no namespace exclusivity — bridges and bots cannot be run against this homeserver) and the Push Gateway API. Both now have their own sections with per-surface rows. - Push was the most misleading entry. Push-rule CRUD is genuinely
spec-covered, butGET /_matrix/client/v3/pushersreturns a hardcoded{"pushers":[]},POST /pushers/setvalidates the body and discards it, and nothing ever posts to a gateway's/_matrix/push/v1/notify— the sole reference to that path is an outbound-URL validator. No user can receive a push notification. The capability row now carries a split status and the endpoint rows are markedscaffolded. - Newly recorded as unrouted:
POST /search(full-text event search; only the unrelateduser_directory/searchexists),GET /notifications,GET /rooms/{roomId}/context/{eventId}(permalink resolution, user-visible in Element — since routed, see above),POST /user/{userId}/openid/request_token, SSO login (m.login.ssois not advertised), andm.ignored_user_listenforcement — the account-data key is storable because that store is generic, but ignored users are never filtered from/sync, so the ignore silently does nothing. - The document gained a "Reading this document" note recording the two failure modes this audit exposed: silence in the ledger does not imply coverage, and a routed endpoint returning 200 does not imply an implemented one.
- Step 1 of closing the push gap: a new
pushmodule (pusher persistence, push-rule evaluation, and a Push Gateway API client), built but not yet wired into the client-server router — routing is a follow-on. Delivery is disabled by default (server.push.enabled = false) so merging this cannot cause an existing deployment to start sending gateway traffic on upgrade. - New
008_pushersmigration (schema version 8) adds apusherstable keyed on(user_id, app_id, pushkey)per the spec's uniqueness rule, withstore_pusher/find_pusher/delete_pusher/list_pushers_for_useronPersistentStore, following thePersistentThreePidBindingpattern. merovingian::push(include/merovingian/push/push_rules.hpp,src/push/push_rules.cpp): a pure, side-effect-free push-rule evaluator.parse_push_ruleset()parses a stored ruleset once into a typedPushRuleset;evaluate_push_rules()evaluates one event per call against it, honouring override > content > room > sender > underride precedence,.m.rule.master's absolute priority, the "never notify for your own events" rule, and all six spec condition kinds:event_match,contains_display_name,room_member_count,sender_notification_permission,event_property_is, andevent_property_contains. The latter two were initially stubbed toPushConditionKind::unknown(never-match) — closed in the same branch, since.m.rule.is_user_mention/.m.rule.is_room_mentionare built on exactly these two kinds and would otherwise never fire, silently disabling @-mentions. Both share the evaluator's existing dot-separated-path resolver (resolve_event_property, refactored out ofevent_match's string-only lookup) so the\./\\escaping rules (docs/matrix-v1.19-spec/appendices.md#dot-separated-property-paths) are honoured identically across all three key-based condition kinds;valuecomparison is exact-type (a string"true"never equals booleantrue, per spec) and fails closed on unresolvable paths or non-array targets.merovingian::push::PushGatewayClient(include/merovingian/push/push_gateway_client.hpp,src/push/push_gateway_client.cpp): an outboundPOST /_matrix/push/v1/notifyclient built on the SSRF-safehttp::OutboundClient+CachedServerDiscoverypattern used byIdentityServerClient— a pusher's gateway URL is client-supplied, attacker-influenced data, so it is resolved the same fail-closed way federation and identity-server traffic is, never via ad-hoc DNS.notify()fails closed with no network call at all whenserver.push.enabledis false, and surfaces the spec'srejectedpushkey list to the caller without touching the database itself.-
New
config::PushConfig(server.push.*:enabled, connect/total timeouts), modelled onOidcConfig/IdentityServerConfig; restart-required on change, same lifecycle as the identity-server client. -
Step 2: wired the
pushmodule into the homeserver — push notifications are now actually delivered.GET /pushersandPOST /pushers/setno longer lie to the client, and a sent event can now reach a real Push Gateway. GET /pushersreturns the caller's persisted pushers in the spec'sPushershape instead of a hardcoded empty array.POST /pushers/setpersists viastore_pusher;kind: nullnow deletes viadelete_pusherinstead of being a silent no-op. Implemented theappendfield per spec:append: false(the default) removes any other user's pusher sharing the sameapp_id+pushkeybefore storing this one;append: trueleaves it in place. The existing body validation, including thehttps+/_matrix/push/v1/notifypusher-URL check, is unchanged.- Delivery:
room_service.cpp'ssend_event()— the single choke point for bothPUT .../send/{eventType}/{txnId}andPUT .../state/{eventType}/{stateKey}(both rewrite to it) — now, after persisting an event, evaluates that event against every local joined recipient's push rules (default_push_ruleset(), the exact rulesetGET /pushrulesalready serves — moved to a new sharedhomeserver/default_push_ruleset.{hpp,cpp}so the two can never drift apart) and builds one Push Gateway notification per (recipient, "http" pusher) pair whose evaluation says "notify".counts.unreadreusessync::count_notifications/sync::read_receipt_ordering(the same unread baseline/syncalready computes) summed across every room the recipient is joined to, rather than a second implementation. "email" pushers are accepted and persisted but not yet deliverable — no email transport exists — and are simply skipped at delivery time. - Gated and off the request path. All of this is skipped entirely —
no rule evaluation, no pusher lookup, no thread — when
server.push.enabledisfalse(the default), so merging this cannot cause an existing deployment to start sending gateway traffic on upgrade. When enabled, the actualPushGatewayClient::notify()calls run on a detachedstd::asynctask parked inHomeserverRuntime::orphan_futures_— the same mechanismjoin_room's background member-fill task already uses — so a slow, hostile, or unreachable gateway can never block or fail the request that triggered it;runtime.mutexis only briefly re-acquired to delete a pusher whose pushkey the gateway reports in itsrejectedarray. - New
push::TestForcedPushGatewayResolutiontest-only seam onPushGatewayClient(mirrorsidentity::TestForcedIdentityResolutionexactly) so integration tests can drive a real local HTTPS mock gateway without weakening the production SSRF/discovery boundary; wired ontoHomeserverRuntime::test_forced_push_gateway_resolution(always empty in production). - New conformance coverage
(
tests/conformance/test_push_notifications_conformance.cpp): the set/get round-trip,kind: nulldelete,appendcross-user removal semantics, and the https/notify-path URL rejection. New integration coverage (tests/integration/test_push_delivery_flow.cpp, against a real local TLS mock gateway): delivery is skipped whilepush.enabledis false; an enabled gateway receives a correctly-shaped notify request for a message that matches a push rule; a rejected pushkey is deleted; and message sending still succeeds — promptly, without blocking — when the recipient's gateway is unreachable. - Known gap, recorded honestly:
GET /notificationsremains unrouted, "email" pushers are accepted and persisted but never delivered (no email transport exists), and there is no gateway retry/backoff (spec SHOULD, not MUST — a failed delivery attempt today is not retried). Seedocs/todos/capability-gaps.md. - Two follow-on fixes to the push delivery path landed in the same branch, closing the gaps the paragraphs above recorded:
- Bounded the background delivery tasks (resource-exhaustion fix).
dispatch_push_deliveriesparked one future per qualifying event inHomeserverRuntime::orphan_futures_and never reaped a completed one — unlikejoin_room's make_join race, which already reaps before parking. Withpush.enabled = truethis was an unbounded memory leak (one entry per event, forever) and unbounded OS thread creation under sustained message volume or a hostile client. Fixed by two changes shared with the join-race path via one helper,reap_completed_futures()(runtime.hpp/.cpp): completed futures are removed fromorphan_futures_before a new one is parked (checked with a non-blockingwait_for(0s), never.get()/.wait()on a still-running one), and a new counter,HomeserverRuntime::push_delivery_in_flight_(guarded by the existingorphan_futures_mutex_, tracked separately from the join race so the two cannot starve each other), is checked against a fixed cap,k_max_in_flight_push_deliveries(128), before a task is spawned. At capacity the delivery is dropped and logged atWARNrather than spawned or blocked on: a missed push is recoverable (the client still sees the event on its next/sync), an exhausted thread pool is not. Newtests/unit/test_runtime_orphan_futures.cppexercisesreap_completed_futures()and the pure cap-boundary check (at_background_task_capacity()) deterministically viastd::promise, with no real threads or timing dependence; new integration coverage intests/integration/test_push_delivery_flow.cppproves the real call site reaps before parking and drops (rather than spawns) once the real counter is saturated. - Membership transitions now notify. Delivery previously fired only
from
send_event().persist_membership_transition— the shared helper behind invite/ban/kick/leave/knock — plusjoin_roomandinvite_user_by_threepidnow route through the same pipeline via a newdispatch_membership_push_notification(). The one correctness subtlety:build_pending_push_deliveries()only evaluated rules againstLocalRoom::members, i.e. joined members, but an invitee is by definition not (yet) joined, so.m.rule.invite_for_me— a default, enabled rule whose entire purpose is to notify an invite recipient — could never have fired even once the pipeline was reachable.build_pending_push_deliveries()now accepts anextra_recipientsspan for exactly this case; an entry equal to the sender or already a joined member is silently absorbed, so every call site passes the membership target unconditionally rather than special-casing which transitions have a real, distinct recipient. New integration coverage proves an invited user's pusher receives the notification, and that a failing gateway never fails or blocks the invite itself. - See
docs/threat-model.md("Push delivery background tasks were unbounded" and "Membership transitions never reached push delivery") anddocs/architecture.md's "Fire-and-forget background work" section for the full design writeup. - Follow-up fix: the in-flight counter introduced above deadlocked runtime
shutdown whenever a push delivery was still running.
dispatch_push_ deliveries's background task decrementedpush_delivery_in_flight_as its final action while holdingorphan_futures_mutex_— the same mutexHomeserverRuntime::~HomeserverRuntime()(and the integration test helperwait_for_background_tasks()) held for their entire drain, including the blockingfuture.wait()calls. A waiter that already held the mutex could never see the task finish, because the task could never acquire the mutex it needed to finish — a classic deadlock. Push delivery is disabled by default, so no running deployment was affected, but any deployment that enables it would hang on server shutdown with a delivery in flight; the integration suite hit this directly (a 583s timeout in "a completed push-delivery task is reaped before the next one is parked"). HomeserverRuntime::push_delivery_in_flight_is nowstd::atomic<std:: size_t>(runtime.hpp). The dispatcher-side check-and-increment stays underorphan_futures_mutex_(already held there to reaporphan_futures_), keeping that read-modify-write correct against the cap; the background task's decrement no longer takes the mutex at all, so its completion can never depend on it. The move constructor and move assignment operator (runtime.cpp) switch fromstd::exchange(not valid on a non-movablestd::atomic) to.exchange(0U).HomeserverRuntime::~HomeserverRuntime()andwait_for_background_tasks()(tests/integration/ test_push_delivery_flow.cpp) now holdorphan_futures_mutex_only long enough to move the parked futures out oforphan_futures_, then wait on the moved-out copies with the mutex released — never holding a lock across a blocking wait. This also stops a runtime shutdown (or a test's background-task wait) from needlessly blocking a concurrentdispatch_push_deliveriescall trying to reap or park a future while the drain is in progress. Audited every other site that parks or reapsorphan_futures_(the make_join race inroom_service.cpp'sjoin_room, twice) and confirmed none of them wait on a future while holding the mutex — this pattern was unique to the two fixed call sites.- New
tests/unit/test_runtime_orphan_futures.cppscenario, "HomeserverRuntime's destructor releases orphan_futures_mutex_ before blocking on a still-running background task": parks a background task gated on an external promise (so it stays in flight, unable to finish, for the whole test), destroys the runtime on another thread, and pollsorphan_futures_mutex_withtry_lock()for up to 2 seconds to prove it becomes available again while the task is still blocked — deterministic, since the task's gate is never opened until after the poll, so a destructor that still held the mutex across its wait would show the mutex held for the entire window, not just possibly missed by a race. - Reviewed "exceeding the in-flight push-delivery cap drops the
notification instead of spawning it" (
test_push_delivery_flow.cpp): it already asserts the cap policy by settingpush_delivery_in_flight_directly to a value at the cap rather than physically spawning 128 real concurrent deliveries against a mock gateway, so it stays fast; no change needed there. The pureat_background_task_capacity()cap-boundary check remains covered separately intests/unit/test_runtime_orphan_futures.cpp. - Closed the
m.ignored_user_listgap the audit above found: the account-data key was storable but the server never acted on it. Implemented server-side enforcement per Matrix v1.19 CS API §Ignoring Users. - New
merovingian::trust_safety::ignore_listmodule (include/merovingian/trust_safety/ignore_list.hpp,src/trust_safety/ignore_list.cpp):parse_ignored_user_list()parses anm.ignored_user_listaccount-data body into astd::unordered_set, failing safe to an empty set on malformed/absent content;resolve_ignored_users()reads a user's global ignore-list row from the store;is_delivery_suppressed()is the pure decision function — non-state events from an ignored sender are withheld, state events are exempt (spec: "Servers must still send state events sent by ignored users to clients"), and a room invite is suppressed regardless of the state-event exemption (spec: "Servers must not send room invites from ignored users to clients"). One shared predicate, called from every delivery surface rather than reimplemented per call site. - Wired into
GET /sync(client_server.cpp): the timeline-matching loop, the invite section (looked up via the inviter'sPersistentInvite:: sender_user_id), andbuild_room_ephemeral_events_array()'s typing and receipt entries. The ignore set is resolved once per request and reused, not re-read per event. - Wired into MSC4186 sliding sync:
build_room_response()(sliding_sync_room_builder.cpp) filters the per-room timeline and, since MSC4186 has no separate invite-state surface like legacy/sync'srooms.invite.<room_id>.invite_state, applies the same invite override insiderequired_statefor the one case a client can still observe an invite: an explicitroom_subscriptionsentry naming a room the caller was invited to.build_extensions()(sliding_sync_extensions.cpp) applies the same filter to the receipts and typing extensions. Both take the caller's resolved ignore set as a parameter, resolved once bysliding_sync_json()rather than per room/extension. - Wired into
GET /messages(messages_json) andGET /context/{eventId}(room_context_json): both now take the requesting user's mxid and drop non-state events from an ignored sender./contextdeliberately never filters the requestedeventfield itself — the caller asked for context around that exact event_id (e.g. a permalink); onlyevents_before/events_afterare filtered. - Wired into push delivery:
build_pending_push_deliveries()(room_service.cpp) checks each recipient's ignore list before even looking up their pushers, so an ignored sender's message — or a membership/invite push routed through the same function viadispatch_membership_push_notification— never reachesdispatch_push_deliveriesand never queues a background delivery task. - Judgement calls: ephemeral typing/receipt entries are treated as ordinary
(non-state) "events sent by that user" and suppressed the same as
messages, since the spec's client-behaviour clause is written in those
general terms and neither is a state event. Sliding sync's room list
only ever enumerates joined rooms (pre-existing, unrelated to this
change) — invite suppression there is provable only via
room_ subscriptions, which is what the new test exercises. - New
tests/conformance/test_ignoring_users_conformance.cpp: an ignored sender's message is absent from/syncwhile an unignored sender's is present; a state event from an ignored sender is still delivered; a room invite from an ignored sender is withheld from/syncwhile another user's invite is not; un-ignoring restores delivery of events sent afterward; a malformedm.ignored_user_listis treated as empty;/messagesand/contextapply the same filter (with/context's target-event exemption). - New
tests/unit/test_trust_safety_ignore_list.cpp: pure-function coverage ofparse_ignored_user_list,resolve_ignored_users,is_delivery_suppressed(including the state-event and invite-override cases and fail-safe behaviour with an empty ignore set), andevent_json_is_state_event's state_key-presence discriminator. -
New scenarios in
tests/integration/test_sliding_sync_flow.cpp(timeline suppression; invite suppression viaroom_subscriptions) andtests/integration/test_push_delivery_flow.cpp(an ignored sender's message, and an ignored inviter's invite, never queue a background push task). -
Routed
GET /_matrix/client/v3/notifications, the last unimplemented piece of this branch's push notification work. Requires authentication; paginates viafrom/next_token, reusing the plain-stream-ordering token formatGET /messagesalready established (next_tokenis thestream_orderingof the first not-yet-returned notification, so a follow-up request withfrom=<next_token>continues without gap or overlap);only=highlightfilters to notifications whose matched rule set the highlight tweak;limitdefaults to 50 and clamps to 1000;readreflects the caller's ownm.read/m.read.privatereceipt in that room, computed at request time via the existingsync::read_receipt_ordering()rather than stored on the row. - New
notificationstable (migrations/009_notifications.sql, schema version 9):user_id,room_id,event_id,stream_ordering,ts,actions,profile_tag,highlight, primary key(user_id, event_id).PersistentNotificationplusstore_notification/list_notifications_for_useronPersistentStore, hydrated for both SQLite and PostgreSQL, following thePersistentPusherpattern. - Notifications are recorded in
room_service.cpp'sbuild_pending_push_deliveries()— the point where an event is already evaluated against each recipient's push rules — for every local recipient whose evaluation resolvesnotify: true. Recording is unconditional onserver.push.enabledand on the recipient having a registered pusher: the spec says the endpoint returns events the user "has been, or would have been, notified about," so a user with push turned off, or never configured a pusher, still needs their history. Only the Push Gateway delivery half of that function (building aPendingPushDeliveryand dispatching it to a real gateway) stays gated onpush.enabled+ a pusher. Suppression for an ignored sender is unchanged — recording happens after the sametrust_safety::is_delivery_suppressedcheck the gateway path already used, so an ignored sender's event is invisible to/notificationstoo, not a separate code path that could drift. PushEvaluationResult(push_rules.hpp) resolves a matched rule down tonotify/tweak_sound/tweak_highlightrather than keeping its rawactionsarray, so a newpush_notification_actions_json()helper reconstructs the conventional shape (["notify", {"set_tweak": ...}]) for storage —profile_tagis left empty, since recording happens once per(user, event)rather than once per pusher and so cannot name one pusher's configured tag.- Retention:
store_notificationprunes the oldest rows for auser_idbeyond a fixed cap,k_max_notifications_per_user(200,persistent_store.cpp), after every insert — the same "bound all resources" policy already applied toorphan_futures_/k_max_in_flight_push_deliveriesfor push delivery's background tasks, now applied to this table's row count. - New
tests/conformance/test_notifications_conformance.cpp: a notification appears with every required field after a matching event;only=highlightfilters to the highlight-tweaked notification (with the unfiltered response as the positive counterpart);limitbounds the page andnext_tokenappears only when more results remain;from/next_tokenpagination collects every notification exactly once with no gap or overlap;readreflects anm.readreceipt (notifications at or before the receipted event become read, later ones stay unread). - New scenarios in
tests/conformance/test_client_server_conformance.cppreplacing the two stale "404 M_UNRECOGNIZED (implementation gap)" placeholders: routed-response shape, and unauthenticated access rejected with 401M_MISSING_TOKEN. - New scenarios in
tests/integration/test_push_delivery_flow.cpp: a message from an ignored sender never appears in the ignoring recipient'sGET /notifications(with a non-ignored sender's message as the positive counterpart), and a notification is recorded even whenpush.enabledis false and the recipient has no pusher registered. -
tests/unit/test_database_persistence.cppandtests/integration/test_persistent_homeserver_flow.cpp: updated the hardcoded migration-step/schema-version assertions for the new schema version 9 and thenotificationsmigration step. -
Implemented Matrix v1.19 OpenID, both halves:
POST /_matrix/client/v3/user/{userId}/openid/request_token(client-server, mints a token) andGET /_matrix/federation/v1/openid/userinfo(federation, redeems it). Closes the last unrouted client-server endpoint this branch's audit had flagged (docs/todos/capability-gaps.md). - Security-critical design constraint: an OpenID token must never be
usable as a client-server access token. Kept structurally separate rather
than relying on a runtime check: a new
openid_tokenstable (neveraccess_tokens), a dedicated mint path (homeserver::request_openid_ token) and a dedicated redeem path (homeserver::federation_openid_ userinfo) that are the only functions touching that table. The ordinary client-server auth gate (authenticated_user) never readsopenid_ tokens;federation_openid_userinfonever readsaccess_tokens/sessions. Seedocs/threat-model.md("OpenID token confusion") anddocs/auth-identity.md("OpenID tokens"). - New
openid_tokenstable (migrations/010_openid_tokens.sql, schema version 10):user_id,token_hash(primary key),expires_at(epoch milliseconds, always finite — unlike access tokens, an OpenID token never has "no expiry").PersistentOpenidTokenplusstore_openid_tokenonPersistentStore, hydrated for both SQLite and PostgreSQL, following thePersistentPusher/PersistentAccessTokenpattern. Retention:store_openid_tokensweeps every already-expired row (across all users) on each insert, since an OpenID token's natural bound is its own expiry rather than a per-user row count. POST /user/{userId}/openid/request_token: authenticated, and the pathuserIdmust equal the caller — a mismatch fails closed 403M_FORBIDDENvia a pure string compare against the already-authenticated user, so the response cannot reveal whether some otheruserIdexists on this server (verified against both a real other user and a nonexistent one, asserting byte-identical responses). Mints a token good for one hour (hardcoded — not operator-configurable, since a longer-lived OpenID token still cannot reach the client-server surface) and returns the spec's four required fields:access_token,token_type("Bearer"),matrix_server_name,expires_in(seconds). Rate-limited under the same default per-IP bucket every other authenticated non-enumerated endpoint (filter, account_data, pushers) uses.GET /openid/userinfo: per spec, requires no authentication and is not rate-limited — the caller may be any third-party service, not necessarily a homeserver — so it is dispatched entirely outsidefederation::handle_inbound_federation_request's X-Matrix signature-required path, alongside the existingGET /_matrix/key/v2/serverbypass, insrc/homeserver/local_http_router.cpp/src/homeserver/federation_proxy.cpp(is_federation_openid_userinfo_endpoint,federation_openid_userinfo_response). An unknown or expired token gets the identical401 M_UNKNOWN_TOKEN/ "Access token unknown or expired" body in both cases, matching the spec's own error shape, so a caller cannot distinguish "never issued" from "expired".- New
tests/unit/test_openid_token_store.cpp:store_openid_tokenpersists, rejects emptyuser_id/malformedtoken_hash, and sweeps already-expired rows on the next insert while leaving still-valid rows (for other users) untouched. - New scenarios in
tests/unit/test_homeserver_auth_service.cpp:request_openid_tokenreturns all spec-required fields;federation_openid_userinforedeems a minted token, fails closed for an unknown token, and fails closed identically for an expired one (asserted against a real unknown-token call, not just "returns false"); an OpenID token is rejected byauthenticated_userand an ordinary access token is rejected byfederation_openid_userinfo— the two security-critical separation directions. - New scenarios in
tests/conformance/test_client_server_conformance.cpp: successful mint returns all four fields; unauthenticated request rejected 401; requesting a token for another user (both a real user and a nonexistent one) rejected 403 with identical bodies;GET /openid/userinforeturns the correctsubfor a token minted through the real client-server endpoint (full round trip); unknown token rejected 401M_UNKNOWN_TOKEN; an ordinary access token rejected 401M_UNKNOWN_TOKENat/openid/userinfo. tests/unit/test_database_persistence.cpp: updated the hardcoded migration-step/schema-version assertions for the new schema version 10 and theopenid_tokensmigration step.- Three PR #479 review findings closed in the push delivery path — two P1, one P2.
- P1: push notifications never fired for federated-room messages.
Delivery was wired into
send_event()(locally composed events) and the membership paths, but an event accepted over federation persists throughingest_pdu_event()(called from theruntime.federation.pdu_sinkcallback wired bywire_federation_callbacks_implinlocal_http_router.cpp) and only ever published the sync token — it never reachedbuild_pending_push_deliveries/dispatch_push_deliveries. A message from a remote room member, the ordinary federated-room case, therefore produced no/notificationsrow and no Push Gateway request for a local recipient. Fixed with a newhomeserver::deliver_federation_push_notifications()(room_service. cpp/.hpp), called from thepdu_sinklambda after a PDU is accepted — the single convergence point for both the direct main-process federation path and the worker-relayed path, so it cannot double-fire. Reusesbuild_pending_push_deliveries/dispatch_push_deliveriesunchanged rather than a second delivery path; passes the event'sstate_keyas an extra recipient form.room.memberPDUs the same waydispatch_membership_push_notificationalready does, so a federated invite can also fire.m.rule.invite_for_me. New integration coverage intests/integration/test_push_delivery_flow.cpp: a federation-accepted message reaches a local recipient's pusher and/notificationshistory; a locally composed event is still delivered exactly once now that the federation path also dispatches push notifications. - P1: unbounded pushers per recipient.
POST /pushers/sethas no per-user limit on distinct(app_id, pushkey)pairs, and every notify-worthy event copied and processed a recipient's entire pusher list sequentially inside one background task — up toserver.push.total_timeout_seconds(default 30s) per pusher — so a large enough list could keep a task, and repeated across events one of the 128 in-flight slots, occupied for minutes to hours; the existing 128-task cap only bounds task count, not the work inside one task. Fixed by bounding delivery, not registration:room_service.cpp's newk_max_pushers_per_delivery(10, comfortably above a real user's device count) truncates the pusher list actually contacted per event, loggingpush.pushers.truncatedatWARN(never silently) the same way the 128-task cap logspush.delivery.dropped. Registration itself (POST /pushers/set,client_server.cpp) is unchanged — out of this branch's scope, owned by other in-flight work on this PR — so this is a delivery-side bound, not a registration-side rejection; notification history recording is unaffected (still unconditional per recipient, independent of the pusher cap). New integration coverage: a recipient with 12 registered pushers has only the first 10 actually contacted for one event. - P2:
contains_display_nameread the wrong display name. Spec: the condition "matches messages wherecontent.bodycontains the owner's display name in that room" — a per-room value.build_pending_push_ deliveriesinstead read the recipient's account-wide profile row (database::find_profile), so a room-specific membership display name, or a stale account-wide one, evaluated the wrong text — missing a real mention or matching an unrelated one. Fixed with a newroom_member_display_name()helper (room_service.cpp) that resolves the recipient's currentm.room.memberstate event content in that room, falling back to the account-wide profile only when the membership event carries nodisplaynameat all. This resolver remains live infrastructure feedingPushEvaluationContext::receiving_user_display_ nameon every evaluation (see the P2 finding below, which removes the only default rule that consumed it); a follow-on PR #479 review pass found and closed three further defects in this same area — P1 below, plus two more P2s. - P1 (PR #479 review): custom pusher
datamembers were silently dropped, both at delivery and at rest.push_gateway_client.cpprebuilt the notify request'sdataobject from onlydata_format, discarding every other member the pusher'sdatadictionary carried at registration — Push Gateway API v1.19: the Device object'sdatais "the data dictionary passed in at pusher creation minus the url key", i.e. everything else must reach the gateway verbatim (gateways commonly use customdatamembers for routing/credentials). Thepusherstable only ever storeddata_url/data_format, so the loss actually started at registration time. Addeddata_extra_jsontoPersistentPusherand thepusherstable (migrations/011_pushers_data_extra.sql, schema version11) to store the rest of the dictionary, and aPushGatewayDevice::data_extrafield thatbuild_device_object()now merges into the outgoingdataobject (defensively skipping any strayurl/formatmember so the routing URL can never leak into the body andformatcan never duplicate). Gap closed:parse_pusher_set_ body()(client_server.cpp) now captures every member of the request'sdataobject besidesurl/formatintoMatrixPusherSetBody::data_ extra_json, thePOST /pushers/sethandler persists it viaPersistentPusher::data_extra_json,GET /pushersechoes it back in the pusher'sdataobject, androom_service.cpp's pusher-to-PushGatewayDeviceconversion threads it intoPushGatewayDevice:: data_extraso a live delivery actually carries it. New coverage:build_notify_request_bodyforwards customdata_extramembers verbatim and excludes/deduplicatesurl/format;store_pusher/find_pusherround-tripdata_extra_jsonincluding an upsert replacing it;test_client_server.cppprovesPOST /pushers/set→GET /pushersround-trips a custom string and integerdatamember;test_push_ delivery_flow.cpp's "custom pusher data members survive registration, GET /pushers, and reach the push gateway's notify request" scenario proves the full path end to end against a real mock gateway. - P2 (PR #479 review,
client_server.cpp:9071): a faileddelete_pusherwas reported to the client as success. Thekind:nullbranch ofPOST /pushers/setdiscardeddelete_pusher's return value withstd::ignore, so a backend failure on an existing pusher still returned200 {}— the client believes it disabled notifications while the pusher stays live and keeps receiving pushes. Now checks whether the pusher exists first (find_pusher); deleting a pusher that was never registered remains a 200 no-op, matching the endpoint's existing idempotent-retry behaviour and the spec's plain "the pusher ... is deleted" wording, but adelete_pusherfailure on one that DOES exist now returns500 M_UNKNOWN. New unit coverage (tests/unit/test_client_server.cpp): forcing the persistence backend to fail (flippingPersistentStoreBackend::sqlitewith an emptysqlite_path, whichpersist_transaction_to_backendalready fails closed on) on an existing pusher returns an error instead of200, and the pusher remains registered afterward. - P2 (PR #479 review,
client_server.cpp:9089): cross-user pusher replacement was not atomic.append:false(the default) deleted every OTHER user's pusher sharing the targetapp_id+pushkeyBEFORE the replacement pusher was persisted; astore_pusherfailure after those deletions left unrelated users' pushers gone with no replacement ever created, silently disabling their notifications. Reordered: the replacement is persisted first, and the other-user removals only run once that succeeds, so astore_pusherfailure now returns500having deleted nothing. (No store-level API exists for wrapping this upsert and the other-user deletes in one transaction —store_pusher/delete_pushereach commit and update the in-memory mirror independently — so reordering is the fix rather than a new transaction primitive; see the reasoning inclient_server.cpp's comment at the call site.) New unit coverage (tests/unit/test_client_server.cpp): with the same forced-backend-failure seam, aappend:falseregistration that fails to store leaves another user's already-registered pusher for thatapp_id+pushkeyuntouched. - P2 (PR #479 review):
contains_display_namematched the display name as a glob pattern, not literal text. A display name is data supplied by an arbitrary user, not an author-written pattern — matching it with the same glob engine content-rule patterns use meant a display name of literally*matched every message in every room (forcing a highlight regardless of whether the message named that user at all), and?matched any single character. Fixed with a newcontains_literal_word_boundary_substring()(push_rules.cpp) that performs the same word-boundary substring search but compares the candidate substring to the display name byte-for-byte, with no glob interpretation. New unit coverage: a display name of*or containing?no longer matches unrelated message bodies, but does match the literal character; the ordinary case (a plain display name at a word boundary) still matches. - P2 (PR #479 review):
.m.rule.roomnotifand.m.rule.contains_ display_namewere not spec-defined server defaults. CS API v1.19 §push-notifications, "Predefined Rules": "[Changed in v1.17]: the legacy default push rules that looked for mentions in the body of the event were removed." The current spec's complete "Default Override Rules" list has ten entries and does not include either rule_id; both were pre-m.mentions-module rules that scannedcontent.bodyfor literal text (a display name, or"@room") — exactly the false-positive pattern.m.rule.is_user_mention/.m.rule.is_room_mention(structuredm.mentions, added v1.7) replaced: a message merely containing someone's name, or the literal text"@room", as ordinary prose highlighted the recipient even when the sender's event carried nom.mentionsat all and the correct mentions rule did not match. Both were previously added as server defaults (0.11.11) citing an Element SDK client-side warning and claiming they are "MUST per the Matrix v1.18 CS API" — that claim does not hold up against the checked-in v1.18/v1.19 spec text, so both are removed fromdefault_push_ruleset.cpp. Thecontains_display_namecondition kind itself remains spec-valid (deprecated, not removed) and its evaluator is unaffected — only the two default rules that fired it (and the@roombody scan) unconditionally for every recipient are gone. New unit coverage (tests/unit/test_default_push_ruleset.cpp): the default ruleset no longer contains either rule_id; a message body naming the recipient or containing literal"@room"text still notifies via.m.rule.messagebut is never highlighted. Conformance and integration coverage updated to match (test_client_server_conformance.cpp's/pushrules/default ruleset assertion;test_push_delivery_flow.cpp's display-name scenario, which now proves the highlight does NOT fire rather than that it does). - P2 (PR #479 review,
client_server.cpp:6261):GET /rooms/{roomId}/context/{eventId}returned the room's current state, not "the state of the room at the last event returned" (CS API v1.19). When a room's name, membership, power levels, or other state changed after the event a client asked for context around, the response'sstatearray leaked present-day values into what was supposed to be a point-in-time view — a documented deviation when/contextlanded, now fixed properly. Newfederation::resolve_state_event_ids_at()(event_query.hpp/.cpp) reuses the backward event-DAG walkbuild_state_response/build_state_ids_responsealready use to answer the federationGET /state//state_idsendpoints (landed 0.8.10) rather than reimplementing state reconstruction, then folds the pinned event's own state contribution back in when it is itself a state event — the federation endpoints deliberately stop one step short of that (SS APIGET /state: "prior to considering any state changes induced by the requested event"), while CS API/contextwants the state inclusive of the last event returned.room_context_json()now tracks the event_id of the last event actually placed inevents_after(falling back to the requested event itself whenevents_afteris empty) and reconstructsstateat that position instead of callingbuild_current_state_events_array(). New conformance coverage (test_client_server_conformance.cpp): a room's name changes strictly after the last event a boundedlimitadmits intoevents_after;statestill reports the pre-rename name, not the room's present-day one.GET /messageswas deliberately left unchanged and is now explicitly tracked indocs/todos/capability-gaps.mdas a divergence rather than silently inconsistent: itsstatefield is spec'd around chunk-relevance/ lazy-loading ("a list of state events relevant to showing thechunk"), not a DAG position, so this fix's shape does not apply there — it would need its own lazy-loading-aware implementation, which remains unstarted. - P2 (PR #479 review,
config.hpp:107): theserver.push.*config keys were undocumented. Push delivery defaults to disabled (server.push.enabled=false), and the only prior documentation change was a wildcard entry in the reloadability table — an operator had no discoverable way to turn on the delivery path this PR spent considerable effort building.docs/user-manual.md's configuration parameter reference gained a "Push notifications —server.push.*" table (enabled,connect_timeout_secondsdefault10,total_timeout_secondsdefault30, read frominclude/merovingian/config/config.hpp) andconfig/merovingian.conf.examplegained a matching commented-out example section. While in there,server.oidc.*andserver.identity_server.*were found to have the same gap (implemented, config-parseable, but never in the parameter reference table) — an earlier task had matched that precedent instead of fixing it. Both gained their own reference tables too (server.oidc.*'s reloadability was also missing from the reloadability policy table entirely; added asReloadable, matchingreload_policy.cpp's default fallthrough for keys with no explicit rule).
0.11.10¶
Four follow-ons to the 0.11.9 identity-server work: a discovery test-seam for
hermetic IS mocking, bind/unbind/requestToken actually driving the remote
IS, a positive-path 3PID-invite conformance test, and MSC4186 multi-list
room-config combination.
- Identity service: the
bind/unbind/requestTokenhandlers now contact the remote identity server instead of persisting locally only.bind(POST /account/3pid/bindand the legacyPOST /account/3pid) calls IS/3pid/bindand persistsclient_secret+sid(new007_account_threepids_columnsmigration) so the binding can later be unbound.unbind(POST /account/3pid/unbind) anddelete(POST /account/3pid/delete) recoverclient_secret+sidfrom the stored binding and call IS/3pid/unbindusing IS auth mode 2 (sid+client_secret, no bearer); on IS non-support (no-support) the local binding is still removed, but a transport failure now fails closed (502) — silently removing the local record while the IS still holds the binding would orphan it (the user could never unbind it without the storedclient_secret/sid).requestToken(register/account-3pid, email/msisdn) delegatessid-issuance to the IS when the request carriesid_server+id_access_token; local validation is unchanged when they are absent (spec-compliant). A newrequest_msisdn_tokenIS client method is added. Security note:account_threepids.client_secret/sidare stored as TEXT (only populated for IS-bound 3PIDs); the threat and mitigations are documented indocs/threat-model.md. Observable change: unbind/delete on an unreachable IS now returns 502 instead of silently succeeding locally. - Discovery test-seam:
IdentityServerClient::perform()resolves the IS host viaCachedServerDiscovery::lookup_addresses(which rejects loopback) and never settrusted_ca_pem, so a self-signed local mock IS failed TLS — making hermetic IS tests impossible. A newtest_forced_identity_resolutionmap onHomeserverRuntime(mirroring the existingtest_forced_outbound_resolutioncontract) lets tests pin a local IS address and supply an in-memory CA bundle;perform()consults it before discovery. The seam struct lives in theidentityheader to keep thehomeserver → identitydependency direction correct. Always empty in production; SSRF and TLS-trust behaviour is unchanged. - Conformance: a positive-path 3PID-invite scenario
(
tests/conformance/test_3pid_invite_conformance.cpp) proves thatPOST /rooms/{roomId}/invitewith a trustedid_servercontacts the ISstore-inviteand persists anm.room.third_party_invitecarrying the IS-issued token asstate_key(the prior scenario covered only the 403 fail-closed path). A bind/unbind round-trip integration test exercises the mode-2 unbind flow end to end via a mock IS. - Sliding sync (MSC4186): when a room is present in multiple list windows,
the room configs now combine across all of them —
required_stateis the superset,timeline_limitis the maximum,include_heroesis OR'd — per MSC4186 room-config combination. Previously only the first matching list contributed (list-vs-subscription was already combined in 0.11.9; list-vs-list was the remaining gap).
0.11.9¶
Four capability-gap closures: remote 3PID identity-server lookup, MSC4186 required_state merge, admin-route rate limiting, and admin 401/403 consistency.
- Identity service: a new
identitymodule provides an outbound Identity Service API client (store-invite,lookup,bind,unbind,requestToken) that talks to a remote identity server over the SSRF-safe resolver with pinned addresses.POST /_matrix/client/v3/rooms/{roomId}/invitewithid_server/medium/addressnow contacts the identity server'sstore-inviteendpoint and builds them.room.third_party_invitefrom the IS-issued token and public key, instead of minting them locally (local-only minting produced an unverifiable invite — the join-sidethird_party_signedsignature is checked against the IS's key, which the IS never issued). The homeserver fails closed (403) whenid_serveris not an operator-trusted identity server. 3PID bindings are persisted (new006_account_threepids.sqlmigration) instead of held only in memory. A newidentity_server.*config block selects trusted identity servers, allowed bind domains, and timeouts. Note: thebind/unbind/requestTokenhandlers persist locally but do not yet drive the remote identity server — that IS sync is a documented follow-on (tracked indocs/todos/capability-gaps.md) pending a discovery test-seam for hermetic IS mocking. - Sliding sync (MSC4186): when a room is present in both a list window and a
room_subscriptionsentry, the combinedrequired_stateis now the superset (with dedup and wildcard-aware merge), the combinedtimeline_limitis the maximum, andinclude_heroesis OR'd — per MSC4186 room-config combination rules. Previously the subscription overrode the list wholesale. - Admin routes:
/_merovingian/admin/*(health, metrics, audit, media moderation) were previously unreachable in production — the client listener only dispatches/_matrix/client/*and/_matrix/media/*, so admin routes 404'd on the public port and were only reachable via the test-only local router. They are now wired intohandle_client_server_requestand served on the client listener, dispatched to the local router before the general user-token gate sorequire_admin()owns the 401/403 split. They inherit the existingallow()per-IP/per-user/per-route rate limiter (throttled exactly once per request, no double-count); the/_merovingian/admin/prefix is capped at 30/min per IP by default and is operator-tunable viaclient_rate_limits:. Security note: the admin surface is now exposed on the public client port — it remains auth-gated byrequire_admin()(401 for a missing/invalid token, 403 for a valid non-admin token) and rate-limited, so only operators with a confirmed admin session can use it. Counters remain in-memory by design — no per-request database write amplification; the trade-off (counters reset on restart) is documented indocs/http-transport.md. - Admin auth:
/_merovingian/admin/*routes now return 401 for a missing or invalid token and 403M_FORBIDDENfor a valid token belonging to a non-admin user, matching the/_matrix/client/v3/admin/*surface. Previously both cases returned 401.
0.11.8¶
Cross-device verification: notify a user's own devices when one of their devices uploads keys, and prompt a freshly-logged-in device to discover its own user's existing devices.
- E2EE device keys:
POST /_matrix/client/v3/keys/uploadnow records adevice_lists.changednotification for the uploading user, so the user's other devices learn about new or updated devices and query/keys/queryfor them. This fixes the Element/Web verification hang where the receiving device received them.key.verification.requestto-device event but ignored it because it did not have the sender's device keys in its store. - E2EE device lists:
POST /_matrix/client/v3/loginnow records adevice_lists.changedself-notification when a genuinely new device is created. A new device's initial/synctherefore lists its own user indevice_lists.changed, prompting the client to/keys/queryand fetch the user's existing devices' keys on its very first sync — before the existing verified device even knows the new login exists. This closes the reverse-direction gap left by the key-upload fix: the new device (e.g. Element X) previously received anm.key.verification.requestfrom the existing verified session but had not yet fetched the sender's device keys, so matrix-rust-sdk dropped the request ("Could not retrieve the device data ... ignoring it"). The spec sanctions the homeserver adding a user todevice_lists.changedwhen a device's cached list may be stale; a brand-new device's cache is empty. - Tests: added unit scenarios covering same-user cross-device key upload, login-triggered own-device discovery, and the resulting
device_lists.changed//keys/queryround-trip.
0.11.7¶
Sync to-device delivery observability and verification-request coverage.
- Sync: added unit scenarios in
tests/unit/test_sync_handler.cppproving that a same-userm.key.verification.requestto-device event is delivered to the target device on both legacyGET /_matrix/client/v3/syncand MSC4186 Simplified Sliding Sync (extensions.to_device). - Diagnostics:
sync.responseandsliding_sync.responselogs now includeto_device_count,device_changed_count,device_left_count,presence_count,account_data_count, andmax_observed_sync_id, making it possible to verify from server logs whether a to-device message reached the client response.
0.11.6¶
Signing key lifecycle and startup hardening.
- Crypto boundary:
ensure_runtime_server_signing_keynow rejects expired derived signing keys and automatically generates a fresh active key. This fixes a startup failure where the main process had a loaded signing secret but no usable active key, leavingcrypto_providernull and causingGET /_matrix/key/v2/serverto return 500. - Runtime startup:
start_runtimenow fails closed if the key-server response cache cannot be pre-warmed, instead of starting with a broken signing provider. - Tests: added unit scenarios covering expired-key rotation and startup recovery in
tests/unit/test_homeserver_vertical_slice.cpp. - Tests: fixed
tests/integration/test_federation_worker_flow.cppby switchingmake_federation_worker_configfrom in-memory SQLite to a unique on-disk file. This matches how production persistence works across separate database connections so generated signing keys survive between the runtime and worker processes. - Tests: hardened worker seccomp scenario now skips when the test process is root.
A root worker is killed by the filter (SQLite's
fchown()and the capability bounding-set drop both fail), while production workers run as a non-root service user. The worker allowlist itself remains validated by unit tests. - Docs: updated
docs/crypto-boundary.mdto describe automatic rotation of expired keys.
0.11.5¶
Matrix spec v1.19 P2 gap closure.
- Admin trust and safety: added
tests/conformance/test_admin_safety_policy_rules_conformance.cppcoveringGET/PUT/DELETE /_matrix/client/v3/admin/safety/policy_rules/{scope}/{entity}CRUD, 400 for invalid actions, 404 for missing rules, and 403 non-admin guards. - Federation keys: the server now supports multiple simultaneously-active Ed25519 signing keys.
GET /_matrix/key/v2/serverpublishes every valid key inverify_keys, signs the response with the preferred key, and retires superseded keys toold_verify_keys.RuntimeMultiKeyEd25519ProviderandRuntimeSigningKeyStorewire the multi-key behaviour through event signing and federation request signing. - Media: remote thumbnails now use the local sandboxed thumbnailing pipeline after fetching the remote file via the authenticated federation media endpoint.
- Federation media: the authenticated
GET /_matrix/federation/v1/media/download/{mediaId}endpoint now followsLocationredirects in an SSRF-safe way instead of immediately falling back to the deprecated v3 endpoint. - Federation media: hardened the
multipart/mixedparser for authenticated download responses to strict RFC 2046 semantics. Boundary delimiters must now sit on their own line, quoted/unquoted boundary tokens with optional whitespace around=are accepted, preambles before the first delimiter and LF-only transport padding are tolerated, and the parser fails closed unless exactly two parts are present. - Media thumbnails: added conformance coverage verifying that a remote thumbnail request never returns 200 with the full-size original bytes when federation infrastructure is unavailable.
- E2EE keys:
POST /_matrix/client/v3/keys/uploadnow reports one-time key counts per advertised algorithm, and backup session/room/batch deletion routes are verified end-to-end. - E2EE keys:
GET /_matrix/client/v3/keys/changesnow requires and validates bothfromandtosync tokens, filterschanged/leftresults to the requested stream range, and returnsM_MISSING_PARAM/M_INVALID_PARAMfor missing or malformed tokens. - Profile: the Complement fixture
tests/fixtures/complement/client_server_v1_19.jsonnow coversPUT /_matrix/client/v3/profile/{userId}/avatar_url, its cross-user 403 guard, and a follow-onGETthat verifies the stored value. - Room versions: added a runtime conformance scenario that creates a room for every stable version v1 through v12 and verifies each create event records the requested
room_versionviaGET /rooms/{roomId}/state/m.room.create/. - Tests: updated the existing
POST /keys/uploadconformance test to expect the per-algorithmcurve25519count instead of the legacy hardcodedsigned_curve25519aggregate. - Third-party invites:
POST /_matrix/client/v3/rooms/{roomId}/inviteaccepts same-serverid_server/medium/addressinvites, generates a random token and Ed25519 keypair, and persists a signedm.room.third_party_invitestate event.POST /_matrix/client/v3/rooms/{roomId}/joinnow acceptsthird_party_signedobjects for matching same-server invites, verifies the embedded Ed25519 signature against the public keys in the invite event, creates the intermediatem.room.memberinvite event, and completes the join. - Room v12 / MSC4291: added conformance coverage for implicit create, creator privilege, and v12 event IDs; fixed any remaining edge cases.
- Sliding Sync: expanded conformance fixtures and fixed subscription
required_statemerging with list-level defaults. - Federation membership:
send_leaveandsend_knocknow return stripped state, knock responses includeknock_room_state, and a local accept-knock path promotes a knock to join. - Federation PDU verification: added conformance coverage proving
parse_inbound_pdu_envelopeandauthorize_federation_pduhonour the room version for every stable version v1 through v12, including v12 create-event room-id derivation and the rejection of auth_events that list the implicit create event. - Tests: added unit scenarios
E2EE /keys/upload groups one_time_key_counts by algorithmandKey backup deletion endpoints remove sessions, rooms, and all keys for the current versionintests/unit/test_client_server.cpp, extended backup route coverage intests/unit/test_key_api.cpp, plus new unit and conformance tests for the other P2 areas. - Federation: added conformance coverage for
GET /_matrix/federation/v1/hierarchy/{roomId}(200, 404, invalidsuggested_only, missing-provider 501) andGET /_matrix/federation/v1/media/download/{mediaId}(200 multipart/mixed, 404, 451, missing-provider 501, percent-decodedmediaId) intests/conformance/test_federation_space_media_conformance.cpp. - Sync:
GET /_matrix/client/v3/syncnow emits thesummaryobject for every joined room withm.joined_member_count,m.invited_member_count, andm.heroes, per Matrix Client-Server API v1.19. Addedtests/conformance/test_sync_summary_conformance.cppto cover the new fields. - Trust and safety: added conformance coverage for
POST /_matrix/client/v3/rooms/{roomId}/report/{eventId}(200 for valid reports, 400 for malformed bodies) andGET /_matrix/client/v3/admin/safety/reportsintests/conformance/test_safety_report_conformance.cpp. - Admin trust and safety review: added
tests/conformance/test_admin_safety_review_conformance.cppcoveringPOST /_matrix/client/v3/admin/safety/review/{targetType}/{targetId}for room, media, and federation_server targets, empty review bodies, invalid target types (400M_BAD_JSON), and non-admin guards (403M_FORBIDDEN). - Read markers:
POST /_matrix/client/v3/rooms/{roomId}/read_markersnow rejects malformed bodies with 400M_BAD_JSON. Addedtests/conformance/test_read_markers_conformance.cppcovering 200 form.read(with the marker appearing as anm.receiptephemeral event in/sync), 403M_FORBIDDENfor non-members, and 400M_BAD_JSONfor invalid JSON. - Presence: added
tests/conformance/test_presence_conformance.cppcoveringPUT /_matrix/client/v3/presence/{userId}/status200 for valid updates, 403M_FORBIDDENfor cross-user updates, and 400M_BAD_JSONfor malformed bodies. - Receipts: added
tests/conformance/test_receipt_conformance.cppcoveringPOST /_matrix/client/v3/rooms/{roomId}/receipt/{receiptType}/{eventId}200 form.read(with the receipt appearing as anm.receiptephemeral event in/sync), 400M_INVALID_PARAMfor invalid receipt types, 403M_FORBIDDENfor non-members, and 400M_BAD_JSONfor malformed bodies. - Docs: updated
docs/todos/capability-gaps.mdand relevant spec docs; repaired a corrupted capability-ledger table row for Database persistence.
0.11.4¶
Matrix spec v1.19 P1 gap closure.
- Client-server:
GET /_matrix/client/v1/auth_metadatanow returns RFC 8414 / Matrix v1.19 authorisation server metadata when the operator configuresserver.oidc.*. When OIDC is not configured it continues to return404 M_UNRECOGNIZEDas the spec requires for unsupported servers. No actual OAuth 2.0 flow is implemented yet; this change covers discovery only. - Federation: added
validate_federation_tls_origin()infederation/server_discoveryto enforce the TLS-certificate-identity contract from Matrix Server-Server API v1.19 §2. The helper validates direct-name matches, well-known delegation consistency, and rejects IP-literal destinations unless the original server name is the same IP literal (including IPv6 literals). -
Tests: added
tests/unit/test_auth_oidc_discovery.cpp,tests/unit/test_federation_tls_origin.cpp, andtests/unit/test_config_oidc_validation.cpp; extendedtests / conformance / test_server_discovery.cppandtests / unit / test_client_server.cppwith configured - OIDC, disabled - OIDC, and TLS - origin scenarios.- CI : updated.gitleaks.tomlto allowlist the importeddocs / matrix - v1.19 - spec /copy, matching the existing v1.18 allowlist.- Docs : added a "Why C++ and not Rust?" section toREADME.mdexplaining the memory - safety tradeoff and how it's mitigated (banned unsafe primitives, isolated libsodium boundary, BSD platform reach, continuous sanitizer/fuzz verification).##0.11.3 Matrix spec v1.19 behaviour changes(Phase 3) . - Federation : per - room server ACL enforcement(MSC4436 / Matrix v1.19).Added `federation / server_acl` evaluator with case -insensitive glob matching,IP - literal handling, and port stripping .Inbound federation requests to protected endpoints(make_* / send_ * / invite / backfill / state / state_ids / get_missing_events / space_hierarchy) are now rejected with 403
M_FORBIDDENwhen the origin server is denied.PDUs insidePUT / _matrix / federation / v1 / send / {txnId}are checked per - PDU against both the transport origin and the PDU sender's homeserver; room-local EDUs (m.typing,m.receipt) are checked against the transport origin. Rooms with nom.room.server_aclevent continue to allow all servers. - Client - server: encrypted history sharing conformance coverage(MSC4268).The genericPUT / _matrix / client / v3 / sendToDevice / {eventType} / { txnId }plumbing already accepted any to - device type; a conformance test now verifies thatm.room_key_bundlemessages carrying them .history_not_sharedwithheld code are delivered to the target device and retain their event type and content.- Tests : addedtests / unit / test_federation_server_acl.cppcovering glob matching, case -insensitivity,?and*wildcards, IP - literal denial, port stripping, deny - before - allow ordering, and defaultallow_ip_literalsbehaviour .##0.11.2 Matrix spec v1.19 behaviour changes(Phase 2) . - Client - server:GET / _matrix / client / v3 / publicRoomsorder is now server - defined per Matrix v1.19(MSC4423).The implementation already returned rooms in insertion order; a conformance test now documents and protects that server - defined order.- Client - server :m.key_backupaccount data(MSC4287) is now accepted, stored, and retrievable via the existingPUT / GET / _matrix / client / v3 / user / {userId} / account_data / { type }endpoints.- Client - server : custom emoji / image packs(MSC2545) are now accepted:m.room.image_packstate events andm.image_pack.roomsglobal account data round-trip through the existing event and account-data paths. - Database: schema version 4 adds astate_transitionstable (viamigrations/004_state_transitions.sql) so the server can record the previous state event for every(room_id, event_type, state_key)tuple. The client event builder uses this to injectunsigned.replaces_stateinto state events returned by/sync,/rooms/{roomId}/messages,/rooms/{roomId}/members, and other state-event paths, matching the Matrix v1.19 client event format. - Client-server: centralized the client-event serializer so/sync,/rooms/{roomId}/messages,GET /rooms/{roomId}/state, andGET /rooms/{roomId}/event/ { eventId }all use the same helper and produce identicalevent_id/unsigned.replaces_statehandling. Removed the duplicate serializer that previously existed inclient_server.cpp. - Tests: fixed malformed raw-string literal in them.room.image_packconformance and unit fixtures that caused the PUT-state request body to be invalid JSON. - Database: schema version 5 backfillsstate_transitionsfrom existingcurrent_staterows viamigrations/005_backfill_state_transitions.sqlso rooms created before the v4 migration do not lack transition history. - Database:state_transitionsnow has an in-memory hash index, andclient_event_with_id()uses it for O(1) lookups instead of scanning the whole vector when injectingunsigned.replaces_state. - Events: for federated state events,prepare_store_event_with_state()now derivesprevious_event_idby walking the event'sprev_events/auth_eventsgraph to find the actual predecessor state event. It no longer assumes the local arrival-timecurrent_stateentry was the predecessor, which was wrong on forks. - Client-server:GET /_matrix/client/v1/mutual_roomsnow issues opaque server-signed pagination tokens instead of integernext_batchvalues. The tokens are keyed to the deployment and verified on decode, so clients cannot guess or forge pagination positions. - Database: the migration runner now accepts data-only migration statements (INSERT/UPDATE/DELETE) as valid no-op schema-state transitions. The v5state_transitionsbackfill is the first data-only migration; without this fix the runner rejected it and runtime startup failed.0.11.1¶
Maintenance : migrate repository documentation and tooling from Matrix spec v1.18 to v1.19. This is a mechanical update only; no v1.19 behaviour changes are implemented in this release.
-
Regenerated local spec docs under
docs/matrix-v1.19-spec/from docs/matrix-v1.19-spec/index.md - Added generated
docs/matrix-v1.19-client-server-api.md(+ appendix) from the v1.19 OpenAPI definition. - Updated
scripts/fetch_matrix_spec.py,scripts/generate-matrix-v119-spec-doc.mjs(renamed from v118),scripts/repoint_spec_links.py, andscripts/check-conformance-gate.shto v1.19. - Repointed all internal Markdown/source spec links to the v1.19 local docs.
- Renamed test Complement fixtures from
*_v1_18.jsonto*_v1_19.json. - v1.18 spec docs remain in place for comparison.
0.10.63¶
Fix: legacy /sync never reported unread notification counts, so read receipts appeared to do nothing.
- Sync: the classic
GET /_matrix/client/v3/syncresponse'srooms.join.{roomId}object never included the spec-requiredunread_notificationsblock (notification_count/highlight_count). Receipts posted viaPOST /rooms/{roomId}/receipt/{receiptType}/{eventId}andPOST /rooms/{roomId}/read_markerswere correctly stored and echoed back as ephemeralm.receiptevents, but nothing in the legacy sync path recomputed or surfaced the notification counts those receipts should have cleared. Clients that poll/sync(rather than the unstable MSC4186 sliding-sync endpoint) — including Element — therefore had no server-authoritative signal that reading a message cleared its unread state, and the client's unread badge only cleared via its own local heuristic (e.g. sending a reply).count_notifications/count_highlights, previously local helpers insliding_sync_room_builder.cppused only by sliding sync, are now shared (declared ininclude/merovingian/sync/sliding_sync_room_builder.hpp) and used by both sync paths; the legacy/syncroom builder now computes the same receipt-baselined counts viasync::read_receipt_orderingand includes them in everyrooms.joinentry.
0.10.62¶
Security fixes for issues #460-#463 (4 findings from a security review of the codebase), plus a live-incident fix (issue #464).
- Media (issue #460, HIGH):
GET /_matrix/client/v1/media/download/{serverName}/ { mediaId }andGET / _matrix / client / v1 / media / thumbnail / {serverName} / { mediaId }were dispatched before the access-token auth gate, allowing unauthenticated media retrieval by media ID. The v1 routes are now matched and dispatched after the auth gate (line 7774), while the unauthenticated v3 routes remain pre-auth as the spec requires. - Federation (issue #461, HIGH):
handle_send_membership(send_join/send_leave/send_knock) accepted inbound PDUs with no Ed25519 signature verification, content-hash check, or origin/sender-domain check — a federated server with valid X-Matrix credentials could forge any user's membership. The handler now runs the same verification pipeline as the transaction/PDU path:authorize_federation_pdu(signature + key resolution),verify_pdu_content_hash, andsender_domain == request.originassertion, rejecting on any failure. - Federation (issue #462, HIGH):
handle_inviteaccepted invite events with no signature verification and no origin/sender check, then re-signed the forged event with the victim's own key. The handler now verifies the inviting server's signature viaauthorize_federation_pdu, checksverify_pdu_content_hash, and assertssender_domain == request.originbefore dispatching to the invite handler.InviteRequestgained anoriginfield so the handler can enforce the sender/origin match. - Observability (issue #463, HIGH): four
log_diagnostic_auditcall sites passedreq.access_token(the raw bearer token) as theactorargument, persisting valid access tokens to the diagnostic log, in-memory audit_events, and the persistentaudit_logDB table. All four call sites now pass"<unknown>"as the actor, mirroring the established pattern inauth_service.cpp:763. - Federation (issue #464, MEDIUM): the
m.direct_to_deviceEDU handler (local_http_router.cpp) discarded the per-deviceenqueue_to_device_messageresult withstd::ignoreand always returnedEduDispositionStatus::accepted, so a megolm room-key share that failed to persist (e.g. a malformed or empty field on one device entry) was dropped with zero trace — the transaction still ack'd 200,edu_dispatchedstill counted it, and nothing in any log said a share was lost. Diagnosed from a live report of a verified device unable to decrypt messages/media federated in from a Synapse-hosted room ("Unable to decrypt: unknown inbound session").enqueue_direct_to_device_messagesnow returns per-envelope targeted/stored counts; the sink returnsrejected_invalidand logs afederation.edu.direct_to_device.store_incompletewarning (origin, targeted, stored — no key material) whenever a targeted device's message did not persist.
CI fixes¶
- The #463 fix's first attempt added a blanket rule to
contains_sensitive_markerredacting every log field literally namedactor, regardless of value. That breakssrc/observability/AGENTS.md's requirement to loguser_id/device_idfor authenticated request traces and is unnecessary: the actual vulnerability (raw tokens passed as theactorvalue) is already closed at the 4 call sites above, and both #463 regression tests assert on the persistedaudit_events.actorvalue, not this redaction rule. Removed the blanket key-based rule; legitimate actor identifiers (e.g.@alice:test) now survivediagnostic_log_summaryagain. tests/federation_signing_test_support.hpp's newmake_signed_event_json()test helper signed events without ever computing/attachinghashes.sha256(that step is the caller's responsibility in production code — seeroom_service.cpp'scompose_event/handle_send_join/handle_send_leave— but the helper's own doc comment incorrectly claimed it did this). Every "valid signature" fixture built through it therefore failedverify_pdu_content_hash, breaking 9 send_join/invite test scenarios. The helper now computes and attaches the content hash before signing, matching the production sequence exactly.make_signed_fallback_key_json()had a typo dropped from a prior edit —"signatures":"instead of"signatures":{"— producing structurally invalid JSON in every fallback-key fixture built through it, breaking the E2EE key-upload persistence integration test withM_BAD_JSON. Restored the missing{.- Three pre-existing
test_federation_membership_endpoints.cppscenarios (two send_join auth_chain tests, one v2-invite-path-parser test) built their request PDUs as hand-written JSON literals with no real signature and a placeholder"hashes":{"sha256":"x"}— valid before #461/#462 added inbound PDU signature/hash verification to send_join and invite, no longer valid after. Updated their fixtures to use the (now-fixed)make_signed_event_json()helper and a sender domain consistent with the registered remote signing key, so they exercise the new verification path instead of being rejected by it.
0.10.61¶
Fixes for the remaining open issues #411-#457 (29 findings).
- Events (issue #411, HIGH): the state-resolution mainline was built from the head power-levels event's direct
auth_events— all of them, regardless of type — andmainline_comparedefaulted unmatched events to position 0 (the newest anchor). Per rooms/v10 Mainline ordering, the mainline is now onlym.room.power_levelsevents walked transitively (P(i+1)= the PL event inPi'sauth_events), positions are found by walking each event's PL ancestry transitively, and an event with no mainline ancestor gets the ∞ sentinel (sorts first/oldest).auth_eventsis also now read as the v3+ array-of-ids shape (previously parsed as an object, so the walk never saw real auth events at all). Reverse-topological and mainline orderings both gained the spec'sevent_idtie-break. - Events (issue #424, MEDIUM):
resolve_state_v2sorted all conflicted events with the reverse-topological power ordering, then immediately re-sorted the whole list by mainline. It now follows the spec's algorithm steps 1-4: power events (per the spec definition, now implemented asis_power_event) are sorted by reverse-topo power ordering and auth-checked first; only the remaining events are mainline-ordered against the partially resolved power levels and auth-checked second. - Federation (issue #414, HIGH):
sender_domainsplit the sender user ID on the last colon, so@user:example.com:8448resolved to sender domain"8448"— every PDU from a homeserver with a port-suffixed server name failed signature lookup and was dropped. User IDs are now split on the first colon via the newauth::user_id_server_nameidentifier-grammar helper. - Federation (issue #423, MEDIUM):
FederationRuntimeState::audit_eventsgrew without bound (one entry per federation decision) andfederation_audit_is_safescanned the whole vector per call. Converted to a capped (10,000-entry) FIFO deque with an incrementally maintained unsafe-reason counter, making the safety check O(1). - Sync (issue #417, MEDIUM): sliding-sync
notification_count/highlight_countcounted events since the sync position (so an initial sync reported every message ever sent as unread) and theby_notification_countroom-list sort ignored receipts entirely (its receipt scan was dead code). Both now baseline on the user's lastm.read/m.read.privatereceipt via the newread_receipt_orderinghelper, and the user's own events no longer count as unread. - Sync (issue #456, LOW): stream-token
decode_componenthad no length cap, so a 17+-hex-digit component wrappeduint64_t— a craftedpostoken could decode to an arbitrarily small ordering and defeat the pos never-regress guarantee. Components longer than 16 hex digits are now rejected. - Sync (issue #457, LOW):
client_event_jsonappendedevent_idwithout checking whether the stored JSON (v1-v3 rooms, foreign-server PDUs) already carried one, producing duplicate keys. Any existingevent_idmember is now replaced. - Config (issue #421, MEDIUM):
build_reload_planemitted no diff forserver.cors.*,server.turn.*,security.secrets.master_key_file,security.trust_safety.*, token lifetimes,federation.worker.*,log_modules.*, orclient_rate_limits.*— edits to those keys were reported as "no changes" and silently dropped. All blocks now diff (maps entry-by-entry);security.secrets.master_key_fileandfederation .worker.*are flaggedrestart_required(loaded / spawned at startup) .http::RateLimitPolicygained a defaultedoperator==for the map diffs.- Config(issue #422, MEDIUM) :RuntimeConfigSnapshot::apply_reloadmutated the liveConfigin place with no synchronization whilecurrent()advertised concurrent - reader safety.The snapshot is now an immutableshared_ptr<Config const>swapped under a mutex;current()returns the shared snapshot. - Homeserver (issue #431, MEDIUM):
policy_server_timeoutseconds were multiplied by 1000 inuint32_t, wrapping for values above ~49.7 days. The millisecond value is now computed in 64 bits and saturated at theuint32_tceiling. - Config (issue #455, LOW):
parse_i64_valueaccumulated the magnitude in a signed 64-bit value and rejectedINT64_MIN. The magnitude is now accumulated unsigned with a sign-aware limit, so the full i64 range parses and out-of-range literals are still rejected. - Core (issue #426, MEDIUM): the sync
timeoutquery-parameter parser had no overflow guard — an overlong decimal wrapped modulo 2^64 into an attacker-chosen effective timeout for the sync pool. Now overflow-checked; overflowing values are discarded.- Core(issue #440, LOW) :percent_decode/percent_decode_path_componentsilently mapped invalid hex(e.g.% ZZ) to NUL bytes.Malformed escapes are now kept literal.- Core(issue #439, LOW) :close_from_directoryduplicated the walk fd without adding the dup to the keep set — on Linux the dynamic/ proc / self / fdsweep closed the dup mid - iteration and its RAII owner closed the same fd again.The purposeless dup is removed.- Media(issue #429, MEDIUM) :decoder_policycomputedmax_upload_bytes * max_decompression_ratiounsaturated(its siblingworker_planalready saturated); extreme config wrapped the decode budget to a small value.Both now share asaturating_scalehelper.- Media(issue #446, LOW) : thumbnail requests fell back to serving the full - size original when thumbnailing was disabled , the worker was missing , or generation failed — a bandwidth - amplification vector(a 32x32 request answered with a multi - megabyte blob) .The endpoint now returns 404(thumbnails unavailable/thumbnail generation failed) instead.- Media(issue #449, LOW) : the thumbnail worker's per-axis 4096 cap still admitted a 4096x4096 RGBA output buffer (64 MiB), and a crafted IHDR could stack allocations toward the RLIMIT_AS ceiling. A worker-imposed ~4.1 Mpixel budget now applies to both the decoded frame and the resampled output, and requests cannot raise it. - Canonical JSON(issue #430, MEDIUM) :make_signable_object_viewdid not stripsignatures/unsigneddespitesrc / canonicaljson / AGENTS.mdclaiming it did.It now elides both top - level keys per the spec's Signing JSON procedure;docs/canonical-json.mdupdated to match. - Canonical JSON(issue #435, LOW) :format_doubleusedsnprintf/strtod, both locale - dependent — under a comma - decimalLC_NUMERIClocale floats serialized as1 , 5(invalid JSON) .The formatter now normalizes the active locale's decimal separator back to.(keptsnprintf-based becausestd::to_charsfor doubles is unavailable on several supported toolchains). - Crypto(issue #433, LOW) :IpcStreamCipher::decryptignored the secretstream tag , silently acceptingTAG_FINAL/TAG_REKEYas normal messages.Any tag other thanTAG_MESSAGEis now rejected.- IPC(issue #451, LOW) :IpcChannel::build_frameemitted{ "id" : 1, }(trailing comma — invalid JSON) for a body of exactly{ }, which would make the peer mark the channel unhealthy.Empty - object bodies now produce a well - formed frame.- Auth(issue #438, LOW) : the/ devices / { deviceId }route matcher was a bare prefix check, matching/ devices /(empty id) and/ devices / foo / bar(extra segments) .It now requires a single non - empty id segment, consistent with the key - API matcher.- HTTP(issue #441, LOW) : outbound response header values kept trailing optional whitespace; RFC 7230 §3.2.4 trailing OWS is now stripped alongside leading OWS.- Homeserver(issue #452, LOW) : exact - equality route matches compared the raw target(including the query string) —POST / logout / all ? ...404'd — and the/ syncprefix match over - matched/ syncXYZ.Route matching now compares the path component(target_pathhelper);/_matrix/key/v2/serverin the local router likewise. - Homeserver (issue #453, LOW):
is_federation_send_targetscanned the whole target (including the query string) for the/send/prefix, letting?x=/_matrix/federation/v1/send/ymisroute a request past the worker. Now matches the path component only. - Homeserver (issue #454, LOW): the admin review/policy-rule path parsers kept the query string and never percent-decoded, persisting rules under entities like
@alice:example.org?notify=1that never match later lookups. Both parsers now strip the query and percent-decode the id segment. - Database (issue #447, LOW): SQLite connections now pin
PRAGMA synchronous = FULLandPRAGMA journal_mode = DELETEat open, so environment-level default pragmas cannot silently weaken durability. - Database (issue #448, LOW):
media_blobs.bytesandserver_signing_keys.secret_keywereTEXTcolumns (andsecret_keynullable); binary payloads containing NUL also truncated on reload because the SQLite reader used a C-string constructor. Columns are nowBLOB(secret_keyNOT NULL DEFAULT '', folded into001_initial_schema.sqlper pre-1.0 migration policy) and column reads are length-based. - Tests: new/updated unit and conformance coverage for every fix above, including state-resolution mainline conformance scenarios, an identifier-grammar scenario for port-suffixed server names, receipt-baselined notification-count scenarios, a concurrent-reader reload scenario, and a NUL-byte media blob round-trip.
0.10.60¶
Security fixes for issues #409-#450 (21 findings from a security review of the codebase).
- Authorization (issue #409, HIGH): kick/ban/unban in
events/authorization.cpponly checked the sender's power against the kick/ban level, nevertarget_power < sender_power— a power-50 moderator could kick or ban a power-100 admin. Kick/unban now follows spec rule 5 exactly (unban requires meeting both the ban and kick levels, and outranking the target); ban now requires meeting the ban level and outranking the target (rule 6.2). - Authorization (issue #410, HIGH):
m.room.redactionwas authorized againstredact/banpower levels (sender_power >= redact_level || sender_power >= ban_level) instead of the genericevents["m.room.redaction"]/events_defaultpath every other message event uses — both over- and under-enforcement were possible depending on room config. Redactions now fall through to the standard message-event power check;redact/banremain relevant only to * applying * an already - authorized redaction, not authorizing it.- HTTP(issue #412, HIGH) :RateLimitEngine::check()returnedallowed = truewhen both the per - IP and per - user policies resolved tonullopt( e.g.an operator- configured policy withwindow_seconds> 3600, which fails validation) — a misconfigured policy silently disabled rate limiting.Now fails closed , matching the standalonerequest_is_rate_limited()helper;config.cppalso now rejectswindow_seconds > 3600at config-validation time. - HTTP (issue #427, MEDIUM): rate-limit bucket tables (
m_ip_buckets/m_user_buckets) were unboundedstd::vectors scanned linearly per request — an attacker rotating a client-suppliedX-Forwarded-Forvalue could grow the table and the per-check cost without bound. Switched tostd::unordered_mapwith a 100,000-entry cap and stale/oldest-entry eviction. - HTTP (issue #413, HIGH):
mero_curl_write_headerwasnoexceptbut performed unbounded allocation with no cap on header count or size — a hostile federation peer streaming enough response headers could trigger abad_allocthat callsstd::terminateand takes down the whole process. Header storage is now capped (256 headers / 64 KiB) and wrapped intry/catch. - Homeserver (issue #415, HIGH):
resolve_policy_server_hook()(a synchronous outbound HTTP call to the configured policy server) was invoked whilehandle_federation_http_requeststill heldruntime.mutex, so a slow or unreachable policy server froze every other consumer of that mutex — effectively the whole process — for up topolicy_server_timeout. The hook now runs after the guard is released. - Federation (issue #416, HIGH):
FederationRuntimeState::accepted_transactionsgrew without bound — every accepted transaction with a distincttransaction_idwas appended and never evicted. Converted to a capped (10,000-entry) FIFO ring. - Media (issue #418, MEDIUM): the internal media pipe body hardcoded
scanner_cleanto the literal"clean"regardless of any scan result, making the quarantine-on-infection path structurally unreachable even withsecurity.media.enable_av_scanner=true.client_server.cppnow runs a deterministic EICAR-test-signature check and threads the real verdict through (Merovingian does not integrate a real AV engine — this makes the existing policy-hook design actually live for the one universally-recognized scanner-integration test case). - Crypto (issue #419, MEDIUM):
IpcStreamCipher's secretstreamState(holding the derived session keys) was never zeroized on destruction. Added aStatedestructor thatsodium_memzeros both directions. - Crypto (issue #432, LOW):
crypto_kx_keypair's return value was ignored inIpcStreamCipher's constructor; a failure would send an uninitialized public key to the peer and derive session keys from an uninitialized secret key. Now checked and fails closed. - Homeserver (issue #420, MEDIUM): the audit-sink database pointer is
thread_local, installed only on the thread that constructsHomeserverRuntime(main) — HTTP handlers run onThreadPoolworker threads, where the sink silently no-op'd (e.g. everyregistration_policy.deniedaudit row was dropped in production).ThreadPoolgained anon_thread_starthook, wired inmain.cppto install the audit database on every worker thread in both HTTP pools. - Federation (issue #425, MEDIUM): the typing EDU handler never checked that
content.user_id's domain matched the verified envelope origin (a cross-origin identity spoof), and extractedroom_id/user_idby scanning for the next"instead of parsing JSON (an escaped quote inuser_idtruncated the extracted value). Now validates the domain viauser_belongs_to_origin()and parses with the canonical JSON parser. - Platform (issue #428, MEDIUM):
__NR_personalitywas unconditionally in the seccomp allow-list (for ThreadSanitizer'spersonality(ADDR_NO_RANDOMIZE)call) in both the main and worker filters, even in production builds — letting code with arbitrary-write RCE disable ASLR. Gated behind__has_feature(thread_sanitizer)/__SANITIZE_THREAD__in both filters. - Auth (issue #434, LOW):
password_matches/registration_token_matchespassedstring_view::data()tocrypto_pwhash_str_verify, which requires a null-terminated C string — safe today only because every caller passes astd::string. Now copies into astd::stringfirst. - Auth (issue #436, LOW):
hash_access_token_v2is an unkeyedcrypto_generichash— a DB leak allows offline rainbow-table recovery of token plaintexts. Still used as a fallback when no master key or signing key is configured; issuing a token under this fallback now logs atoken.unkeyed_hash_fallbackwarning so operators notice the degraded mode. - Auth (issue #437, LOW):
redacted_token_for_logdisclosed the exact token byte length, a minor side channel distinguishing token versions and valid- from invalid-length presented tokens. Now discloses only a coarse size bucket (tiny/short/medium/long). - Database (issue #442, LOW): PostgreSQL's
create_table_if_missing_sqlconcatenatedtable.name/columns_sqlinto DDL with no identifier validation or quoting, unlike the SQLite path. Now applies the sameschema_table_is_corevalidation andquote_sqlite_identifierquoting. - Media (issue #443, LOW):
media_id_is_validrejected/and..but not embedded spaces, diverging frommedia_id_is_safe's stricter check. Unified to reject the same shapes. - Homeserver (issue #444, LOW): the download/thumbnail route parser (
local_media_download_parts) accepted..and embedded spaces in themedia_idpath segment, caught only later at the repository boundary. Now rejects the same shapes the admin media routes already do. - Media (issue #445, LOW):
sniff_mime_type's text/plain heuristic classified any all-printable-ASCII blob astext/plain, including an ASCII HTML/JS polyglot — since text/plain is in the default MIME allow-list, a declared-text/plainpolyglot upload had no declared-vs-sniffed mismatch to catch it. Content that opens with<(after leading whitespace) is no longer classified as text/plain. - Homeserver/federation (issue #450, LOW): documented (in
docs/threat-model.mdand at both call sites) that main'spdu_sinkdoes not independently re-verify a PDU's Ed25519 signature before persisting it — it relies on the worker (or the transaction handler, for the non-worker path) having already verified viaauthorize_federation_pdu. Accepted as a defense-in-depth gap given the existing worker-trust model; full re-verification would require plumbing the raw PDU and a main-side-resolved key through a different envelope shape thanInboundPduEnvelopeprovides today. - Tests: new/updated unit, integration, and conformance coverage for every fix above, including a new
tests/unit/test_ipc_stream_cipher.cpp(previously no test coverage existed forIpcStreamCipherat all).
0.10.59¶
- Security/crypto-boundary (issue #396): consolidate all libsodium access in
src/crypto/,src/auth/,src/events/, andsrc/core/secret_buffer.cpp;src/ipc/channel.cppnow usescrypto::IpcStreamCipherfor KX + secretstream. - Hand-rolled JSON scanners (issues #397, #401, #403): replace
worker_pool.cppjson_get_u64,federation_request_routing.cpp/sendroom_idextraction, andipc_ed25519_provider.cppsign_responseparsing withcanonicaljson::parse_json()and typed accessors. - Backfill query hardening (issue #399):
parse_backfill_query()checkserrno == ERANGEafterstd::strtoull()and rejects out-of-rangelimitvalues. - File-descriptor close path (issue #400):
close_all_file_descriptors_except()checkserrno != 0afterstd::strtol()on/proc/self/fdentries and rejects overflowed names. - Sync-pool plain-socket writes (issue #402): add a looped
send_all()helper so short::send()writes on the sync-pool plain-socket path are retried instead of truncated. - Constant-time comparison (issue #405):
crypto::constant_time_equal_variable_length()now returnsfalseif any libsodiumcrypto_generichash_*step fails, eliminating the fail-open all-zero digest path. - Key-backup version enforcement (issue #404): modifying session endpoints require the requested
?version=to match the current backup version and return403 M_WRONG_ROOM_KEYS_VERSIONwithcurrent_versionwhen it does not; GET endpoints filter by the requested version and default to the current version when it is omitted;delete_all_key_backup_sessionsnow respects aversionparameter. - Registration token loading (issue #406): read token file directly into
core::SecretBuffer, fail closed whenmlockfails, and hash viaauth::hash_registration_token. - Update
scripts/reject-unsafe.shanddocs/security-coding-rules.mdto encode the permitted libsodium boundary and reject regressions. - Add unit and conformance tests covering the key-backup version enforcement paths.
- Add unit tests for the new crypto-boundary helpers (
secure_random_bytes,secure_random_hex,to_hex, base64,generic_hash,ed25519_sign_detached,ed25519_verify,RuntimeEd25519Provider) and auth helpers (token_secret_has_required_entropy,token_hash_is_persistable, access-token v2/v3/v4 hashing,password_matches, registration-token hashing).
0.10.58¶
Changed¶
- docs: restructure
README.md. Reordered around a fixed top-level structure — latest-release line, badges, table of contents, "What is Merovingian and what makes it special?", Release Artifact Verification, Installation and Configuration, Starting Merovingian for the first time, Upgrading Merovingian, Troubleshooting, and Getting Started With Development. The former "Project Status" and "Secure by design" sections were folded into the new "What is Merovingian" section; new Installation/Configuration, first-start, upgrade, and troubleshooting sections summarize and link out to the correspondingdocs/user-manual.mdsections rather than duplicating them. - docs: rewrite the "What is Merovingian and what makes it special?" section to make the security case explicitly, adding a "Why choose Merovingian over other homeservers" subsection grounded in concrete, existing mechanisms: the mechanically-enforced ban on raw
new/delete/malloc/free/raw pointers, seccomp/pledge/Capsicum-sandboxed worker processes for untrusted media decoding and federation traffic, fail-closed startup self-checks and signature/config validation,mlock()'d/auto-zeroising secret storage with constant-time comparisons, narrow crypto module isolation, default-quarantine media handling and architectural opacity of E2EE attachments, redaction-aware structured logging with durable security audit rows, and continuous sanitizer/fuzzing/static-analysis/secret-scanning CI gates — with links out todocs/threat-model.md,docs/hardening.md,docs/crypto-boundary.md, anddocs/security-coding-rules.mdfor the full detail. - docs: fix an inaccurate claim in the opening paragraph of the same README section. It previously said the homeserver "holds every private conversation, encryption key, and access token" — wrong: Matrix's E2EE model keeps clients' private encryption keys and plaintext entirely client-side, and the homeserver only ever sees ciphertext for encrypted rooms (plus public device/one-time-key material and, if key backup is used, client-encrypted backup blobs it cannot read). Reworded to scope the server's actual exposure correctly: federation identity, access tokens, message metadata, and plaintext only for rooms that aren't end-to-end encrypted.
0.10.57¶
Added¶
- feat(events): implement third-party (3PID) invite authorization — Matrix spec rule 4.3.1. An
m.room.memberevent withmembership: "invite"and acontent.third_party_inviteproperty was previously authorized by the generic invite path (target-not-joined/banned, sender-joined, invite-power), silently ignoring the 3PID token and signature entirely —MembershipPolicy::third_party_inviteandAuthEventMap::third_party_inviteexisted in the type but no production code path ever set or checked them.authorize_event_against_auth_eventsnow implements the full spec sub-tree for this content: reject if the target is banned; reject ifsignedis missing, or missingmxid/token; reject ifsigned.mxid != state_key; reject if nom.room.third_party_invitestate event exists withstate_key == signed.token; reject if that event'ssenderdoesn't match the invite event'ssender; and finally verify an Ed25519 signature insigned.signaturesagainstcontent.public_key/public_keyson them.room.third_party_inviteevent (payload is the canonical{mxid, sender, token}object, matching ordinary Matrix "Signing JSON"). Also implements the separate rule 6 — creating anm.room.third_party_inviteevent itself is now gated on the room's invite power level, not the genericstate_defaultpower every other state event uses. - feat(crypto): add
crypto::ed25519_verify— a stateless Ed25519 verification entry point. UnlikeEd25519Provider::sign, verifying an arbitrary public key needs no signing-key store, so this is a plain free function incrypto/ed25519.hpp/.cppwrappingcrypto_sign_verify_detached, usable from pure modules (events/authorization.cpp) without threading a provider through every call site.RuntimeEd25519Provider::verify(homeserver/runtime.cpp) now delegates to it instead of duplicating the libsodium call. - feat(events): wire
AuthEventMap::third_party_invitepopulation into all three real authorization call sites —room_service.cpp(local event composition),local_http_router.cpp(inbound federation PDU ingestion), andstate_resolution.cpp(state resolution v2) — each now extractscontent.third_party_invite.signed.tokenfrom the event under authorization and looks up the matchingm.room.third_party_invitestate event bystate_key.
Testing¶
- test(events): add 10 conformance scenarios for third-party invite authorization covering: a valid signature (both the legacy
content.public_keyand thecontent.public_keyslist forms), a banned target, a missingsignedproperty, anmxid/state_keymismatch, no matchingm.room.third_party_inviteevent, a sender mismatch, a forged signature, and the separate invite-power-vs-state_default gating rule form.room.third_party_invitecreation.test_event_auth_rules.cpp, using real Ed25519 keypairs viatests/federation_signing_test_support.hpp(authorization now does real cryptographic verification, not a fake provider double).
Notes¶
- Accepting this shape end-to-end —
POST /invitewith a 3PIDaddress/id_server, andthird_party_signedon/join— remains unimplemented. The former needs a real identity-server HTTP client (otherwise there is no real party to sourcepublic_key/public_keysfrom); the latter needs thePUT /_matrix/federation/v1/exchange_third_party_invite/{ roomId}endpoint or same-server authority to sign an intermediate invite event on behalf of the original inviter (a different sender than the joining user) — both larger, separate efforts tracked indocs/todos/capability-gaps.md. The auth-rule engine above already validates either shape correctly whenever such an invite event exists in room state (e.g. created via the genericPUT /rooms/{roomId}/state/m.room.third_party_invite/{ token}endpoint, or received over federation).
0.10.56¶
Fixed¶
-
fix(observability): redact a bare
tokenquery parameter in structured logs.contains_sensitive_markerrecognizedaccess_token/refresh_token/session_tokenand a handful of exact keys, but never the bare keytoken.GET /_matrix/client/v1/register/m.login.registration_token/validity?token=<secret>passes the plaintext registration token as atokenquery parameter, and every request target is logged viasanitized_http_target, so the raw secret was written to structured logs in cleartext.tokenis now in the exact-match redaction list. -
fix(auth): stop requiring an access token on
POST /_matrix/client/v3/refresh. The spec is explicit: "this endpoint does not require authentication via an access token. Authentication is provided via the refresh token."client_auth_endpoint_requires_access_tokenexcluded onlyloginandregister_account, so a client whose access token had already expired — the exact case/refreshexists to recover from — would be rejected before the refresh token in the body was ever inspected.refresh_tokenis now excluded alongsideloginandregister_account. -
fix(net): mark accepted client sockets close-on-exec.
http_server.cpp's plain-HTTP and TLS accept loops used::accept()instead ofaccept4(..., SOCK_CLOEXEC), unlike every other fd-creation site in the codebase (tcp_acceptor.cpp,shutdown_signal.cpp,worker_supervisor.cpp). Because the homeserver spawns worker subprocesses (federation workers, the thumbnail worker) viaposix_spawn/fork()while client connections — including long-poll/sync— remain open, an accepted socket withoutFD_CLOEXECwould be inherited by every subsequently spawned worker for as long as the connection stayed open, leaking live client sockets into unrelated child processes. Both accept loops now useaccept4(..., SOCK_CLOEXEC). -
fix(canonicaljson): fail closed on floats in the signing/hashing serialization path.
serialize_canonical()'s float branch usedstd::to_string(double), which is fixed to 6 fractional digits, not the shortest round-tripping representation the surrounding trim logic assumed — a small magnitude like1e-7serialized to"0.000000", which the trailing-zero strip then collapsed to"0.0", silently corrupting the value. This path is unreachable from event signing today (every signing caller parses withparse_lossless(), which rejects floats at the parse boundary), butserialize_canonical()itself had no float guard, so any future caller building aValuetree with adoubledirectly would silently produce a wrong, non-canonical hash rather than an error. Split into two entry points:serialize_canonical()keeps accepting floats (now via a portable shortest-round-tripping conversion) because it also serves ordinary, never-signed JSON responses that legitimately contain floats (e.g.m.tagorder, account data);serialize_canonical_strict()rejects aValuetree containing any double with the newCanonicalJsonError::float_not_allowedinstead of serializing it, and is now whatevent_signer.cpp,event_id.cpp, andsignable.cppcall for signing/hashing payloads. -
fix(crypto): wrap the operator master key and its derived keys in zeroising, mlocked buffers; consolidate the duplicated master-key loader.
load_master_key_material()read the master key file into a plainstd::vector<std::uint8_t>and an unwiped stack read buffer — neither zeroised normlocked — even though this file is the root secret every derived key (secret-box, access-token HMAC v3/v4, IPC auth) comes from. It now reads into acore::SecretBufferand returns one.SecretBoxKey,TokenHmacKey, andIpcAuthKeypreviously wrapped their 32-byte key material in a barestd::arraywith no destructor; all three now zeroisebyteswithsodium_memzeroon destruction and on the losing side of every copy/move, while remaining ordinary copyable/movable value types (unlikeSecretBuffer, which is heap-based and move-only — a mismatch for these small, pervasively-by-value-passed keys).src/homeserver/room_service.cppduplicatedload_master_key_materialinstead of callingcrypto::load_master_key_material; the duplicate is removed and both call sites inroom_service.cppnow delegate to the singlesrc/crypto/implementation.
Testing¶
- test(observability): assert a bare
tokenquery parameter is redacted insanitized_http_target.test_observability.cpp. - test(auth): assert
/refreshis excluded fromclient_auth_endpoint_requires_access_tokenand from theClientAuthRoute.requires_access_tokenroute table.test_auth_session.cpp,test_auth_client_server_api.cpp. - test(homeserver): add a Linux-only integration scenario that connects a real client, holds the accepted connection open with a deliberately incomplete request head, locates the server's accepted socket via
/proc/self/fd, and assertsFD_CLOEXECis set — verified to fail against the pre-fix::accept()and pass againstaccept4(..., SOCK_CLOEXEC).test_http_server_listener_flow.cpp. - test(canonicaljson): add conformance coverage that
serialize_canonical_strictrejects a double at every nesting depth (bare, in an array, in an object) whileserialize_canonicalstill accepts one; add a round-trip regression test for the1e-7→"0.0"corruption case.test_canonicaljson_serializer.cpp. - test(crypto): add unit coverage for
load_master_key_material— exact-byte round-trip including embedded NUL, fail-closed on empty/missing/oversized/exactly-at-cap files, and an end-to-end check that the returned buffer derives working keys through every downstream KDF (derive_secret_box_key,derive_token_hmac_key[_v3],derive_ipc_auth_key).test_crypto.cpp.
Changed¶
- chore(release): bump version to 0.10.56 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.55¶
Changed¶
- docs: consolidate every security-relevant rule scattered across ~25 module
AGENTS.mdfiles into a single, explained reference. Rules like "all sockets must be opened withO_CLOEXEC/SOCK_CLOEXEC" (src/net/AGENTS.md) live in terse, per-directory form acrosssrc/auth/,src/canonicaljson/,src/config/,src/core/,src/crypto/,src/database/,src/events/,src/federation/,src/homeserver/,src/http/,src/media/,src/net/,src/observability/,src/platform/,src/rooms/,src/sync/,src/trust_safety/,migrations/,packaging/,scripts/,security/, and everytests/*/AGENTS.md— useful as fast-reference instructions while working in that directory, but with no single place to read the reasoning behind a rule end to end, and no home indocs/where it's visible without already knowing whichAGENTS.mdfile to look in. Addeddocs/security-coding-rules.md: every rule above, grouped by theme (memory safety, secrets/logging, cryptography, auth/authz, federation, HTTP/network, database, media, platform hardening, sync, trust & safety, packaging, testing requirements, process), each with a why (the concrete vulnerability class or failure mode it closes, citing the CWE where one applies and the specific fixed bug where the rule traces to one logged indocs/threat-model.md) and a source pointer back to the owningAGENTS.mdfile, plus an index-by-source-file for the reverse lookup.security/coding-rules.md(previously the closest thing to an authoritative security rule list — terse, CWE-tagged, but missing most of the module-specific rules and not discoverable fromdocs/) is now a pointer to the new document;security/AGENTS.md, the rootAGENTS.mdkey-docs list,docs/AGENTS.md's update-trigger table,docs/coding-rules.md, andREADME.mdall updated to reference it. Every moduleAGENTS.mdfile is left untouched — this is purely additive.
0.10.54¶
Fixed¶
-
fix(events): reject a self-leave (
membership: "leave",sender == state_key) unless the sender's current membership isinvite,join, orknock.authorize_event_against_auth_eventsallowed self-leave unconditionally, with a comment noting "unless banned in some room versions" that was never implemented. Per the room v10–v12 authorization rules ("If the sender matches state_key, allow if and only if that user's current membership state is invite, join, or knock"), a banned user could send a self-leave event to flip their own membership frombantoleave, then knock or rejoin under normal join rules — a ban-evasion authorization bypass.authorization.cppnow checkstarget_current_membershipbefore allowing a self-leave. -
fix(events): reject
m.room.memberevents with an unrecognizedmembershipvalue instead of silently treating them asleave.parse_membership_statefell through toMembershipState::leavefor any string outsidejoin/invite/leave/ban/knock, contrary to the spec's "Otherwise, the membership is unknown. Reject." Combined with the self-leave bug above, a malformedmembershipvalue (e.g. from a buggy or adversarial remote server) could be admitted into room state under the guise of a "leave".parse_membership_statenow returnsstd::optional<MembershipState>; the top-levelrequestedmembership on a new event is rejected when unrecognized, while internal lookups of already-accepted prior state fall back toleave(the safe "not a member" default). -
fix(federation): reject inbound PDUs verified against a signing key that has passed its
valid_until_ts.remote_key_cache.cpp's resolver falls back to a stale cached key (cache.stale_fallback) when a live refresh fails, by design, so callers can distinguish "known but currently unreachable" from "never seen" — butauthorize_federation_pdunever checked the returned key's expiry before using it to verify a PDU's Ed25519 signature. If a remote server's key was rotated after a compromise and the old server became unreachable, an attacker holding the old private key could keep forging PDU signatures indefinitely.authorize_federation_pdugained anow_tsparameter; when non-zero, a PDU verified against an expired key is now rejected with "sender domain signing key has expired". (The equivalent check already existed for X-Matrix request auth inverify_signed_federation_request— this closes the same gap for PDU signature verification.) -
fix(media): sniff actual upload content instead of copying the client-declared
Content-Typeinto both the "declared" and "sniffed" MIME fields.client_server.cppbuilt the internaldeclared_mime|sniffed_mime|scanner_clean|bytespipe body by duplicating theContent-Typeheader into both fields, which madeevaluate_media_upload's declared-vs-sniffed mismatch check permanently a no-op (it always compared a value to itself). An attacker could upload arbitrary content (e.g. HTML with an embedded<script>) while declaring an allow-listedContent-Typesuch asimage/png, and the quarantine control meant to catch exactly that never fired. Addedmedia::sniff_mime_type()(magic-byte detection for PNG/JPEG/GIF/PDF, a printable-ASCII heuristic fortext/plain, falling back toapplication/octet-stream);client_server.cppnow sniffs the real request body for local uploads, andrepository.cpp'sfetch_remote_mediasniffs the real response body for federated media fetches (a remote server's declaredContent-Typeis equally untrustworthy). -
fix(media): reject a
Content-Typeheader containing|on media upload instead of splicing it unescaped into the internal pipe format. The internaldeclared_mime|sniffed_mime|scanner_clean|bytesprotocol relies on|as a field delimiter;http::header_value_is_valid()permits|in header values, so a client-controlledContent-Typecontaining|could shift the parsed field boundaries and forge thescanner_cleanflag and leading body bytes seen bylocal_http_router.cpp'ssplit_pipe_4.client_server.cppnow rejects such uploads with400 M_BAD_REQUESTbefore constructing the pipe body.
Testing¶
- test(events): add conformance coverage for self-leave from
banand from no prior membership, and for an unrecognizedmembershipvalue — all now rejected.test_event_auth_rules.cpp; also updatedtest_state_resolution_conformance.cpp's conflicting-membership fixtures, which had unintentionally relied on the self-leave bug (theirm.room.memberevents carried nomembershipfield at all) to reach "exactly one winner" — fixtures now use real join/leave content plus a shared publicjoin_rulesevent so both conflict candidates are independently auth-valid. - test(federation): add unit coverage asserting a PDU verified against an expired signing key is rejected when
now_tsis provided, accepted when the key is still valid, and accepted via the pre-existing 3-arg overload (now_tsomitted, expiry check skipped).test_federation_inbound_request.cpp. - test(media): add unit coverage for
sniff_mime_type()(each recognized signature, the text/plain heuristic, empty input, non-printable fallback, and a disguised-HTML payload) and forevaluate_media_uploadquarantining a declared/sniffed mismatch.test_media_security.cpp. Added a unit scenario asserting aContent-Typecontaining|is rejected with 400 rather than reinterpreted.test_client_server.cpp. Fixed pre-existing fixtures acrosstest_media_repository.cppandtest_client_server_conformance.cppthat used placeholder ASCII bodies (e.g."png-bytes","test-image-data") declared asimage/png, which the now-functional sniffer correctly flags as a mismatch — replaced with real PNG magic bytes.
Changed¶
- chore(release): bump version to 0.10.54 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.53¶
Fixed¶
- fix(sync): Simplified Sliding Sync
timelineandrequired_stateevents now carryevent_idinstead of the raw stored PDU JSON.build_room_responsepushed a room's stored event JSON (ev.json) straight into the response for both the timeline and required_state arrays. Stored event JSON is the signed wire PDU format, which never carriesevent_id— it is derived from a reference hash and only ever injected for client-facing responses (seeclient_server.cpp'sclient_event_value(), used correctly by/syncand/messages). Sliding sync skipped that conversion entirely, so every timeline and required_state event was missingevent_idand additionally leaked federation-only PDU fields (auth_events,prev_events,hashes,signatures,depth) that must never reach a client. Per the Matrix spec's Room Event Format,event_idis mandatory on every client-facing event;ruma-events(the crate matrix-rust-sdk uses to parse each sliding sync timeline entry) rejects an event missing it withmissing field 'event_id'. Because this failure happens per-event inside the client's timeline diff — not at the top level of the sliding sync response — the outer response still parsed and committed normally,poskept advancing, and the connection never looked broken in server logs: the client silently dropped every single message while everything else (typing, receipts, room-list counts) kept working. Reported as "no messages are arriving on the mobile client", initially misdiagnosed as media-specific because a manual server-side repro of a plaintext send/ingest path had not yet exercised the exact Element X connection shape. Root-caused by reproducing that exact shape, capturing the real response body, and feeding it through the actualruma-eventsdeserializer: confirmedmissing field 'event_id'before the fix and a clean parse after.build_room_responsenow converts stored events through a newclient_event_json()helper (mirrorsclient_event_value()) for both timeline and required_state entries.
Testing¶
- test(sync): assert sliding sync timeline and required_state events carry
event_idmatching the real sent/stored event, not the raw PDU shape. Regression coverage intest_sliding_sync_flow.cppagainst the exact Element X"room-list"connection request shape.
Changed¶
- chore(release): bump version to 0.10.53 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.52¶
Fixed¶
-
fix(sync): reject a Sliding Sync
posahead of the live stream with400 M_UNKNOWN_POSinstead of ratcheting it — inbound messages were silently never delivered after a server restart. The pos handling deliberately never regressed a token component: the returned pos wasmax(current watermark, requested pos), on the theory that a lower cursor would make clients retry indefinitely. In reality, when a client presents a pos ahead of the live stream — matrix-rust-sdk persistsposacross app and server restarts (share_pos), while the server's in-memory stream watermark is rebuilt on startup and can come up lower than the previous lifetime's — the ratchet made the stale token permanent: the client'ssincefloor sat above the live stream, so every new inbound event landed in the gap and was filtered as already-seen, the long-poll parked, the room was skipped, and the server echoed the inflated token back, forever. Outbound sending kept working (the send path never consultspos), which produced the confusing "can send but never receive" symptom. Production logs showed this live: a client polling with pos event-component 920 while the server's stream sat at 918. Per MSC4186, a pos the server cannot serve is now rejected with400 M_UNKNOWN_POSand the connection state is dropped; matrix-rust-sdk handles this by expiring the session and starting a fresh initial sync (ErrorKind::UnknownPos→expire_session()), so clients self-heal immediately. The returned pos is now always the live watermarks. -
fix(database): persist the timeline stream-ordering watermark so it survives restarts (migration
003_event_stream_watermark). This is the root cause behind clients holding apos"from the future":next_stream_orderingwas rebuilt on startup frommax(events.stream_ordering) + 1, but membership stream positions consume orderings without a backing event row, so every restart rebuilt the counter lower than the previous lifetime's — putting every client's persisted sliding syncpos/sincetoken ahead of the live stream. All 16 stream-ordering allocation sites now flow through a newhomeserver::allocate_stream_ordering()helper that persists the high-water mark to a newevent_stream_watermarksingleton table (mirroring the existingsync_stream_watermarkfrom migration 002), and hydration takes the maximum of the persisted watermark and the highest persisted event ordering. Combined with theM_UNKNOWN_POSfix above: restarts no longer regress the stream at all, and the rejection path remains as a safety net for tokens that predate this migration.
Testing¶
- test(sync): assert a future pos is rejected with 400 M_UNKNOWN_POS and that the follow-up no-pos request receives a full initial snapshot. Replaces the previous "never regresses a client stream position" scenario, which asserted the harmful ratchet behaviour this release removes.
- test(database): assert the stream-ordering counter and pre-restart sliding sync pos survive a SQLite restart. A regression scenario creates a room (consuming non-event-backed membership stream orderings), records
next_stream_orderingand a sliding syncpos, restarts the runtime from the same SQLite file, and asserts the counter did not regress and the oldposis served rather than rejected withM_UNKNOWN_POS. Migration inventory tests updated for schema version 3 (47 tables, 3 migrations).
Changed¶
- chore(release): bump version to 0.10.52 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.51¶
Fixed¶
- fix(sync): wrap the receipts/typing extensions' per-room payload in a type-tagged event instead of sending bare content.
m.receiptandm.typingareEphemeralRoom-kind events (ruma'sSyncReceiptEvent/SyncTypingEvent) — the wire shape is{"type":"...","content":{...}}.build_receipts/build_typingwere serialising only the bare content object (e.g.{"$event_id":{"m.read":{...}}}with notype/contentwrapper at all). Verified directly against ruma-client-api's own deserializer: the bare shape fails withmissing field 'type'. matrix-rust-sdk treats a failed extension deserialization as a failed sync iteration — the response is accepted with200at the transport level, but the client never commits the newposand immediately retries the same request, producing an unbounded busy-loop storm on any poll that returns a non-empty receipt or typing notification. This was invisible until 0.10.50'srooms:["*"]fix started actually delivering receipts to Element X for the first time — receipts had always been malformed, but the earlier "*" bug meant they were silently never sent, so the malformed shape never reached a client that would choke on it. Root-caused from a real capture: a fresh sliding sync response was generated locally, matched byte-for-byte against the connection shape reported in production logs, and fed through a standalone Rust program depending onruma-client-apito confirm the exact deserialization failure before and after the fix.
Testing¶
- test(sync): assert extensions.receipts/typing wrap content in
{"type":...,"content":...}, not bare content. Regression coverage for the deserialization-storm fix; also updated the existingbuild_extensionsunit coverage intest_sliding_sync_surfaces.cpp, which had asserted the old, broken bare-content shape.
Changed¶
- chore(release): bump version to 0.10.51 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.50¶
Fixed¶
-
fix(sync): scope the Sliding Sync long-poll wake check to the extensions and lists a connection actually requested. #375/#376/#377/#378 scoped the wake check to rooms the caller has joined, but it still woke a connection for any signal relevant to the user anywhere — a new timeline event in any joined room, or a receipt/typing update in any joined room — regardless of whether that connection had asked for rooms, receipts, or typing at all. Element X runs a dedicated background sliding sync connection with no
lists/room_subscriptionsthat only enables theto_device/e2eeextensions, used to keep encryption keys flowing without paying for room-list computation. Every message, read receipt, or typing notification in any of the user's rooms woke that connection early, and since it had nothing new to report it replied instantly with an empty snapshot at the samepos, which the client immediately re-polled — a busy-loop sync storm on that connection (observed assliding_sync.dispatch/sliding_sync.responsepairs firing every 70-150ms) while the user was simply active in an unrelated room. The wake check now only considers a room timeline event relevant when the connection has at least one list or room subscription, and only considers a device-list-change/to-device/account-data/receipts/typing row relevant when the connection's request enabled that specific extension. -
fix(sync): treat
"rooms":["*"]in the receipts/typing extensions as "all subscribed rooms", not a literal room ID. MSC4186'sAllSubscribedsentinel (ruma'sExtensionRoomConfig::AllSubscribed) serializes to the JSON string"*", and matrix-rust-sdk'sRoomListService— used by Element X — sends exactly"receipts":{"enabled":true,"rooms":["*"]}on every sliding sync request.effective_rooms()only fell back to "all rooms in the response" whenroomswas omitted entirely; a present-but-["*"]array was matched literally against room IDs, which never matches, so read receipts were silently never delivered to Element X via the receipts extension.effective_rooms()now also treats a single"*"entry as no filter. -
fix(sync): resolve
required_state's"$LAZY"and"$ME"sentinel state keys instead of matching them literally. matrix-rust-sdk'sDEFAULT_REQUIRED_STATE(used by Element X'sRoomListService) always requests["m.room.member","$LAZY"]and["m.room.member","$ME"]. The matcher comparedstate_keyby plain string equality, and a realm.room.memberevent'sstate_keyis always a user ID — never the literal string"$LAZY"or"$ME"— so Element X never received a singlem.room.memberevent from sliding sync, breaking member lists, display names, and heroes resolution."$ME"now resolves to the requesting user's own ID."$LAZY"now resolves to the members relevant to the timeline this response returns (senders and membership-change subjects) when the timeline is truncated or this is the room's first appearance on the connection — matching matrix-rust-sdk/Synapse's lazy-loading behaviour — and to them.room.memberwildcard otherwise, so the client's existing membership cache stays valid across continuous incremental syncs. A member relevant for the first time on a connection is delivered even if their own membership event predates the incremental since-floor, tracked via a new per-connectionlazy_members_sentset (mirrors the existingrooms_seencommit-on-ack pattern) so it isn't endlessly re-sent afterward.
Testing¶
- test(sync): assert that an e2ee/to_device-only Sliding Sync long-poll parks through a typing notification and message it did not request. A regression scenario mirrors Element X's background connection (no lists/subscriptions, only
to_device/e2eeenabled); after bob posts a typing notification and sends a message in a room alice has joined, alice's long-poll (can_wait=true) must returnneeds_wait, not an emptycompleteresponse. - test(sync): add end-to-end coverage that mirrors matrix-rust-sdk's actual
SyncServicerequest shapes. Reproduces Element X's two concurrent sliding sync connections byte-for-byte — the"room-list"connection's exactDEFAULT_REQUIRED_STATElist,timeline_limit, andaccount_data/receipts(rooms:["*"])/typingextensions, and the"encryption"connection'sto_device/e2ee-only, no-lists shape — against a live homeserver instance. Covers:m.room.encryptionis delivered viarequired_stateon a private_chat room; alice's own member event ("$ME") and bob's ("$LAZY", the most recent timeline sender) are both delivered; the encryption-only connection parks through a message/typing/receipt it never asked for; the encryption-only connection wakes and deliversdevice_lists.changedon a key upload; the room-list connection's receipts extension delivers a receipt requested viarooms:["*"]. - test(sync): unit-test
"$ME"/"$LAZY"required_state resolution directly againstbuild_room_response. Covers:"$ME"resolves to only the requester's own member event;"$LAZY"on an initial/truncated-timeline response scopes to just the members whose events are actually in the delivered timeline (not every joined member); a member newly relevant on this connection has their membership delivered even when it predates the since-floor, and is not re-sent once already delivered;"$LAZY"and"$ME"resolve correctly together, matching Element X's real combined request.
Changed¶
- chore(release): bump version to 0.10.50 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.49¶
Fixed¶
- fix(sync): scope the Sliding Sync long-poll wake check to the requesting user's own rooms. The handler's "has anything relevant changed" gate compared the request's
sincecursor against the homeserver's global event-stream watermark: any event in any room on the server (not just rooms the caller has joined) made the gate treat the wait as satisfied and fall through to building a response immediately, even thoughrooms{}had nothing new for this connection. On a multi-room/multi-tenant server the global watermark advances constantly, so long-polls effectively never blocked — the client received a storm of near-instant, emptyrooms_in_response=0responses with aposthat kept advancing regardless. Because the server marks a room's state as "seen" by this connection as soon as a response referencing it round-trips, room state (includingm.room.nameandm.room.encryption) that arrived inside one of these rapid-fire responses could be marked delivered without the client ever reliably applying it, since the server never resends unchanged state. The wake check now only treats a new room event as relevant when it landed in a room the caller has joined, matching the existing scoping already used for receipts and typing.
Testing¶
- test(sync): assert that a Sliding Sync long-poll parks rather than returning early for an event in a room the caller has not joined. A regression scenario has two users, each with their own room; after alice completes an initial sync, bob sends a message in his own room and alice re-polls with a non-zero timeout from her prior
pos. It asserts the handler returnsneeds_waitinstead of an emptycompleteresponse.
Changed¶
- chore(release): bump version to 0.10.49 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.48¶
Fixed¶
- fix(sync): return MSC4186
rooms[roomId].timelineas a plain[Event]array, not a/v3/sync-style object. The room response previously serialisedtimelineas{events, limited, prev_batch}, which Element X / matrix-rust-sdk does not accept for MSC4186 Simplified Sliding Sync. The malformed response caused the client to reject the returnedposand replay the same request position, so the connection state never committed and rooms stayed invisible. The timeline field is now a direct array of events as required by the proposal. - fix(sync): stop computing per-room
prev_batchandlimitedfor Sliding Sync. These fields are not part of the MSC4186 room object; removing them also removes the now-unusedSlidingSyncRoomResponse::prev_batchandlimitedmembers.
Testing¶
- test(sync): assert that the v4 sliding sync response returns
timelineas a JSON array. A regression scenario creates a room, sends a message, and performs an initial sliding sync viaPOST /_matrix/client/v4/sync. It verifies thatrooms[roomId].timelineis an array containing the message event and that the legacy{events, limited, prev_batch}object shape is absent.
Documentation¶
- docs: document the MSC4186 room object fields and the array timeline shape.
docs/matrix-v1.18-client-server-api.mdnow lists each field inrooms[roomId]and explicitly notes thattimelineis a plain[Event]array, not the/v3/syncobject.
Changed¶
- chore(release): bump version to 0.10.48 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.47¶
Documentation¶
- docs: reference the latest raw MSC4186 proposal and document the stable v4 endpoint shape.
docs/matrix-v1.18-client-server-api.mdnow links the authoritative raw proposal text (https://raw.githubusercontent.com/matrix-org/matrix-spec-proposals/refs/heads/main/proposals/4186-simplified-sliding-sync.md), lists all three routed endpoints (v4,org.matrix.msc4186,org.matrix.simplified_msc3575), and documents body-levelpos/timeoutand the singularrangekey.
Changed¶
- chore(release): bump version to 0.10.47 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.46¶
Documentation¶
- docs: fix accuracy errors and gaps found in a full documentation audit.
canonical-json.mdno longer claims signed-64-bit integer enforcement (actual: JS-safe-integer range) and now documents theparse_json()general-purpose parser.auth-identity.mdno longer lists device-list sync, key-count responses, and key-backup retrieval/deletion as deferred — all three are implemented — and a botched-merge formatting defect is fixed.log-filtering.mdanduser-manual.mdaudit-event tables now includerequest.user_locked/request.user_suspended.http-transport.mdno longer describes single-mutex runtime serialisation (actual: 256-way room-striped mutex) and now documents the dedicatedsync_pool.user-manual.md's hardening self-check table now reflects the real compile-time/runtime probes instead of describing every check as an unimplemented placeholder, and adds theno_new_privs/capability boundingrows.architecture.mdnow mentionstests/conformance/in the testing section.todos/production-milestone.mdstrikes through the token-hashing and Argon2id items, which were already implemented.tests/smoke/AGENTS.mdno longer describes a nonexistent--smokeflag or live-listener checks that the smoke suite doesn't perform.security/coding-rules.mdentries now cite a CWE or named vulnerability class persecurity/AGENTS.md's own requirement; the style-only rules it previously mixed in (member prefix, include ordering/quoting) moved todocs/coding-rules.md.
Changed¶
- chore(release): bump version to 0.10.46 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.45¶
Fixed¶
- fix(sync): treat initial Sliding Sync rooms on a fresh connection as full snapshots, not as deltas from a reused pos. Element X sometimes creates a new
conn_idand supplies aposfrom another connection or device. The server was using that position as a per-room since floor, so initial rooms reported zero unread notifications for events at or before the borrowed position and could appear stale. Room-level deltas now use0for initial rooms and only apply the requestposto rooms this connection has already seen.
Testing¶
- test(sync): cover fresh Sliding Sync connections that reuse a pos from another conn_id. A regression scenario creates three joined rooms, sends a message in one, obtains a
poson a seed connection, then syncs with a newconn_idreusing thatpos. It asserts that all three rooms are returned withinitial=trueand that the room containing the message reports a non-zero unread notification count.
Changed¶
- chore(release): bump version to 0.10.44 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.43¶
Fixed¶
- fix(sync): preserve an unacknowledged Sliding Sync snapshot for request retries. The server previously committed list windows and room inclusion immediately after writing a response. If Element X cancelled or retried an initial request with the same prior
pos, the retry received an empty list and no rooms despite never acknowledging the original response. Connection state now commits only after the client supplies the returned position; retries are rebuilt from the last acknowledged snapshot. - fix(sync): never regress a Sliding Sync position after watermark reconstruction. Responses now retain each component of a client position when the server's reconstructed stream watermark is behind it, preventing Element X from rejecting the lower cursor and retrying the same room-list request in a tight loop.
Testing¶
- test(sync): cover retries before Sliding Sync position acknowledgement. A regression scenario asserts that retrying a fresh connection with the same
posretains the originalSYNClist operation and room payload. - test(sync): cover a client position ahead of the reconstructed Sliding Sync watermark. A regression scenario asserts that the returned position never moves backwards.
Changed¶
- fix(ci): make NetBSD package installation resilient to mirror outages. NetBSD CI now retries both official binary-package endpoints and checks dependency installation before invoking the build, avoiding a misleading missing-compiler failure when a mirror refuses a transient connection.
- chore(release): bump version to 0.10.43 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.41¶
Testing¶
- test(sync): add an opt-in live Sliding Sync long-poll probe for pong.ping.me.uk. The test authenticates only when MEROVINGIAN_LIVE_ACCESS_TOKEN supplies a short-lived token, creates an isolated conn_id, and verifies that a current pos is held for the requested long-poll interval rather than immediately reissued. It skips without network access or a token and never logs credential or response content.
Changed¶
- docs(testing): document safe authenticated live client-server probes.
- debug(sync): add startup-gated Sliding Sync response diagnostics. Starting the server with --debug emits a request-shape summary with safe connection metadata, positions, counts, enabled extension names, and response size. It never logs connection IDs, request bodies, tokens, event content, or encryption material, and cannot be enabled through per-module log configuration.
- chore(release): bump version to 0.10.41 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.
0.10.40¶
Fixed¶
- fix(sync): sliding sync room list now uses the persistent store as the source of truth.
compute_room_listpreviously enumerated joined rooms from the runtime cache (rt.database.rooms) while reading membership and metadata from the persistent store. This caused rooms to disappear from sliding sync when the runtime cache was stale or unhydrated, notably making rooms created on one device invisible to a second device using MSC4186 sliding sync. The room list is now built directly fromstore.roomsandstore.memberships, matching the database of record.
Testing¶
- test(sync): unit and integration coverage for cross-device sliding sync visibility. A new unit test proves
compute_room_liststill reports a room that exists only in the persistent store, and a new integration test verifies that a room created on a desktop device appears in the sliding sync response for the same user on a mobile device.
Changed¶
- chore(release): bump version to 0.10.40 across
meson.build,src/main.cpp,src/db_migrate.cpp, packaging metadata, build scripts, andCHANGELOG.md.
0.10.39¶
Fixed¶
- fix(ci): actually run GPG signing and rolling-latest publishing after skipped optional jobs. The previous attempt used bare
needs.<job>.result == 'success'conditions, but GitHub Actions still implicitly applies a defaultsuccess()check to any job-levelifthat does not contain a status function. Because optional jobs such asnetbsd-pkg-retryare skipped, that defaultsuccess()returned false and kept skippingsign-package-assets,publish-latest,sign-release-assets, andpublish-alpha-release. All four jobs now use!failure() && !cancelled()together with the explicit result check so they run whenever their direct dependency succeeds, regardless of skipped upstream jobs.
Changed¶
- chore(release): bump version to 0.10.39 across
meson.build,src/main.cpp,src/db_migrate.cpp, packaging metadata, build scripts, andCHANGELOG.md.
0.10.38¶
Added¶
- feat(rate-limit): production-grade client-server rate limiting. Per-IP and per-user wall-clock token-bucket tiers are enforced before dispatch. Per-user buckets are keyed by the authenticated
user_id; per-IP buckets use the effective client IP plus a normalized route. Path parameters (roomId,deviceId,mediaId, etc.) are coalesced into placeholders so a single cap covers all rooms/devices/media. Exceeded caps return429 M_LIMIT_EXCEEDEDwith aRetry-Afterheader (and the deprecatedretry_after_msbody field). Operators configure overrides viaclient_rate_limits.per_ip.<target>,client_rate_limits.per_user.<target>, andclient_rate_limits.default_per_ip. - feat(voip): static TURN credentials for
GET /_matrix/client/v3/voip/turnServer. Newserver.turn.server,server.turn.username,server.turn.password, andserver.turn.ttl_secondsconfig keys supply static credentials to authenticated VoIP clients. When no TURN server is configured the endpoint returns an empty JSON object so clients gracefully disable relay support. -
feat(federation): inbound
GET /_matrix/federation/v1/media/download/{mediaId}endpoint. Remote homeservers can download locally-uploaded media usingX-Matrix-authenticated requests. The route bypasses the federation worker (which has no access to the local media repository) and returns amultipart/mixedresponse per Matrix v1.18. -
feat(supply-chain): immutable dependency pinning for
catch2andyyjson. Both wraps are now[wrap-file]entries pointing at release tarballs with SHA-256source_hashvalues.scripts/verify-wrap-pins.shand an updatedtests/tooling/test_dependency_wraps.pyenforce that every committed wrap is a file wrap with a 64-character hash. - feat(license): release-attached license review.
docs/dependencies/licenses.mdrecords the license and GPL-3.0-or-later compatibility of every direct and transitive image-codec dependency. Thedependency-review-actionlicense check is enabled,scripts/generate-license-summary.pyemits a machine-readablemerovingian.licenses.json, and the JSON is attached to alpha and rolling-latest releases. - feat(release): GPG-signed release artifacts. A reusable
.github/workflows/sign-artifacts.ymlworkflow imports an offline maintainer GPG key and produces detached.ascsignatures for every tarball (alpha releases) and every package plusSHA256SUMS(rollinglatestreleases). Verification instructions are documented indocs/release-process.mdand the release checklist. - feat(reproducible-builds): verify static Linux tarball reproducibility.
scripts/reproducible-build.shbuilds the static Linux fallback tarball twice with the sameSOURCE_DATE_EPOCHand compares SHA-256 hashes.scripts/build-static-linux.shnow defaults to release builds with--strip, setsSOURCE_DATE_EPOCHfrom the commit author date, and uses deterministic GNU tar options when available. A dedicated CI job runs the verification on every push tomainand on PRs touching the build.
Changed¶
- docs(http-transport, user-manual, capability-gaps, build-warning-policy, release-process, security-review-checklist, dependencies): document the implemented rate limiter, TURN configuration, inbound federation media serving, dependency pinning policy, license review, GPG signatures, and reproducible build verification; update
config/merovingian.conf.example. - chore(release): bump version to 0.10.38 across
meson.build,src/main.cpp,src/db_migrate.cpp, packaging metadata, build scripts,README.md, anddocs/user-manual.md.
Fixed¶
- fix(build): avoid
_FORTIFY_SOURCEmacro redefinition on musl/Alpine builds. Hardening flags now emit-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3so the project's level-3 fortification overrides any toolchain-default level-2 definition without triggering-Werror. - fix(config): keep public registration disabled in
config/merovingian.conf.example. The example config now defaults tosecurity.registration.enabled=false, so--check-configand--dry-runsmoke tests that copy the example file no longer require a real/etc/merovingian/registration-token. - fix(ci): make
scripts/reproducible-build.shwork whenactions/checkoutdoes not materialize a.gitdirectory. The script no longer relies ongit rev-parse --show-toplevel; it derivesSOURCE_DATE_EPOCHfrom git when available and falls back to0otherwise. - fix(ci): ensure GPG signing and rolling-latest publishing run after package staging. Added explicit
needs.<job>.result == 'success'conditions tosign-package-assets,publish-latest,sign-release-assets, andpublish-alpha-releaseso the defaultsuccess()graph (which can be false when optional retry jobs are skipped) no longer skips the signing and publishing jobs.
Testing¶
- test(rate-limit): new and updated unit tests cover route normalization, per-user keying by
user_id,Retry-Aftersemantics, and config validation. - test(federation, media): route coverage, federation-worker bypass, multipart/mixed envelope construction, and end-to-end handler tests cover the inbound federation media download endpoint.
- test(tooling):
tests/tooling/test_dependency_wraps.pynow asserts[wrap-file]pinning with SHA-256 hashes, the absence of[wrap-git]entries, per-dependency review documents with license notes, and a valid machine-readable license summary JSON.tests/tooling/test_packages_workflow.pyconfirms the version bump is consistent across packaging metadata. - test(release):
scripts/check-release-readiness.shnow gates the presence of the license review document, the wrap-pin verification script, and the GPG signing workflow references inrelease.ymlandpackages.yml.
0.10.36¶
Added¶
- feat(config): explicit
listeners.*.reverse_proxydeclaration for listener security. Public (non-loopback) client and federation listeners must now use TLS withreverse_proxy=false. Loopback cleartext listeners are only permitted whenreverse_proxy=trueis explicitly declared, matching the reverse-proxy-first deployment model. Configuration validation rejects ambiguous or unsafe combinations before startup.
Changed¶
- docs(user-manual, http-transport, threat-model): document the explicit reverse-proxy listener model and public TLS requirement.
0.10.35¶
Fixed¶
- fix(federation-proxy):
FederationProxy::handle()now matchesGET /_matrix/key/v2/serverexactly rather than by substring, so a malformed target such as/_matrix/federation/v1/state/!room:example.com/_matrix/key/v2/serveris no longer mis-routed to the local key endpoint. - fix(federation-worker):
WorkerEventLooprequest handlers now checkpool.submit()return value and immediately send a503 M_UNAVAILABLEIPC response if the pool is stopping, instead of silently dropping the request and letting the main process time out. - fix(ipc):
deserialize_outbound_http_response()now preserves the specifichttp::OutboundErrorcode (timeout,connection_failed,invalid_url, etc.) instead of always collapsing failures tonetwork_error. - fix(ipc):
IpcChannel::dispatcher_loop()now catches unhandled exceptions thrown by request handlers, marks the channel unhealthy, wakes every pendingsend_requestwaiter, and exits cleanly instead of callingstd::terminate. - fix(federation):
make_outbound_make_membership()no longer appendsver=query parameters tomake_leaveper Matrix v1.18 (onlymake_joinandmake_knockcarry supported-room-version hints). - fix(federation-worker):
WorkerEventLoop::run()drainslocal_poolandrelay_poolbefore callingchannel->stop(), so in-flight tasks can still send their IPC responses before the fd is closed. - fix(federation-worker):
WorkerPool::handle()now computes the IPC timeout asmax(federation.worker.request_timeout_seconds, security.federation.remote_timeout) + 10 s, so a worker-side outbound HTTP call that legitimately runs longer than the worker request timeout can complete before main declares an IPC timeout. - fix(federation-worker):
WorkerSupervisor::stop()now marks the supervisor unhealthy and uses an atomicworker_pid_so TSan-clean health checks and shutdown are observable from any thread.
Testing¶
- test(federation-worker):
tests/integration/test_federation_worker_flow.cppnow coversWorkerSupervisorbounded stop, unexpected-exit restart/backoff, and SIGKILL escalation when the worker ignores shutdown;tests/unit/test_worker_supervisor.cppcovers the pre-startworker_pid()getter. - test(federation-worker):
tests/unit/test_worker_event_loop.cppnow covers shutdown sequencing and pool-submit-failure response paths. - test(federation-worker):
tests/integration/test_federation_worker_flow.cppexposesFederationProxy::healthy()and replaces the hard-coded 2-second sleep before the outbound proxy test with await_for_healthy(proxy)helper that polls pool health. - test(federation):
tests/unit/test_federation_membership_endpoints.cpppins thatmake_leavetargets carry nover=query parameters whilemake_joinstill advertises supported versions. - test(ipc):
tests/unit/test_ipc_framing.cpppins that an unhandled exception in a request handler does not callstd::terminateand instead makessend_requestreturnnullopt. - test(ipc):
tests/unit/test_ipc_federation_frames.cpppins thatOutboundErrorround-trips throughserialize_outbound_http_response/deserialize_outbound_http_response.
Changed¶
- **chore(release): bump version to 0.10.35 across
meson.build,src/main.cpp,src/db_migrate.cpp, packaging metadata, build scripts,README.md, anddocs/user-manual.md.
0.10.34¶
Fixed¶
- fix(media): federated attachments could be sent but never received.
fetch_remote_media_live()called only the deprecated, unauthenticatedGET /_matrix/media/v3/download/{serverName}/{mediaId}endpoint, which current Synapse and Merovingian deployments disable by default per the Matrix v1.11 spec change — every remote media fetch 404'd. The fetch path now tries the mandatory authenticatedGET /_matrix/federation/v1/media/download/{mediaId}endpoint first (signed with an X-Matrix Authorization header, same as outbound transactions), decodes itsmultipart/mixedresponse (parse_federation_media_multipart()), and falls back to the deprecated endpoint withallow_remote=falseonly on a404, per spec. ALocation-redirect response from the authenticated endpoint is not yet followed (seedocs/todos/capability-gaps.md) and falls back the same way. - fix(media): serving locally-uploaded media to other homeservers over federation is still unimplemented — flagged as a follow-up capability gap in
docs/todos/capability-gaps.md(this server has no inbound route for either the authenticated or deprecated federation media download endpoint).
Added¶
- tests(homeserver): new
remote_federation_media_download_url()andparse_federation_media_multipart()pure functions, covered by unit tests (tests/unit/test_homeserver_media_service.cpp) and a new conformance suite (tests/conformance/test_federation_media_conformance.cpp) citing SS API §Content Repository.
0.10.33¶
Added¶
- docs: new
docs/user-manual.md— a comprehensive operator manual covering installation (packages and source), initial setup, configuration parameters, database backends, running the server, reverse-proxy pointers, user management, federation policy, media repository, logging/diagnostics, maintenance, troubleshooting, and a security checklist. - docs(readme):
README.mdnow links todocs/user-manual.mdand reports the current beta version (v0.10.33). - docs(user-manual): the Reverse proxy section now includes complete, copy-paste configs for nginx, Apache httpd, Caddy, Traefik, HAProxy, and Cloudflare (TLS termination,
.well-knowndiscovery, client/media/federation/key routing,X-Forwarded-Forrate-limit hardening) plus the per-proxy CORS smoke test, folded in from the retireddocs/configuration.md.
Changed¶
- chore(release): bump version to 0.10.33 across
meson.build,src/main.cpp,src/db_migrate.cpp, packaging metadata, and build scripts. - docs:
docs/configuration.mdis retired — every section (fail-closed startup, listener/TLS policy, secrets at rest, registration/token lifetimes, federation inbound/outbound/join/worker controls, size and duration formats, trust-safety transport, reloadability policy, runtime config snapshot, startup hardening self-check, production packaging, CORS, client rate limits, log-module overrides) is now part ofdocs/user-manual.md's Configuration, Federation, and Media repository sections.AGENTS.md,README.md,docs/getting-started.md,docs/hardening.md,docs/http-transport.md,docs/media-repository.md,docs/release-process.md,src/config/AGENTS.md, packaging spec/PLIST files,scripts/build-static-linux.sh, and two source comments now point atdocs/user-manual.mdinstead.
0.10.32¶
Added¶
- feat(federation): inbound
/sendnow applies authenticated per-origin abuse controls: Matrix v1.18 transaction caps are enforced explicitly (50PDUs,100EDUs by default), and each verified remote origin has configurable transaction, PDU, and EDU rate buckets so a burst from one server is throttled by actual federation pressure rather than by spoofable event sender IDs. - feat(config): operators can tune federation abuse limits with
security.federation.max_transaction_pdus,security.federation.max_transaction_edus,security.federation.per_origin_transaction_rate,security.federation.per_origin_pdu_rate, andsecurity.federation.per_origin_edu_rate. - docs(federation): configuration and architecture docs now explicitly distinguish inbound
/sendabuse controls from outbound destination queue/backoff controls.
Fixed¶
- fix(federation-worker): inbound federation dispatch no longer holds
HomeserverRuntime::mutexacross whole/sendtransactions: the previous wrapper lock survived the earlier PDU-stripe and IPC handler-pool fixes, so a worker shard still serialized relayed transactions while each request waited on worker-to-main sink IPC.handle_federation_http_request()now holds the global runtime mutex only for startup checks, callback wiring, key publication, request identity construction, and trust-safety policy lookup, then releases it beforehandle_inbound_federation_request()runs. - fix(federation):
FederationRuntimeStatenow protects its own mutable bookkeeping while federation requests run concurrently: remote records are copied out for request processing, remote signing-key/trust updates are persisted through narrow guarded helpers, and accepted transaction/audit vectors are updated under a federation-local mutex instead of relying on the homeserver global mutex. - fix(federation): valid but excessive
/sendtraffic now returns429 M_LIMIT_EXCEEDEDat the origin policy boundary while individual invalid PDUs still report per-PDU errors in a200transaction response, preserving Matrix federation retry semantics. - fix(federation):
handle_inbound_federation_request()no longer passes a const remote record tocheck_inbound_request_signature()and no longer dereferences a non-optional remote when choosing the PDU signing key, restoring compilation after the burst-delivery refactor. - fix(federation):
federation_summary()now formats per-origin transaction/PDU/EDU rates asN/Ws(e.g.120/60s) so the runtime summary matches the policy syntax and the bounded-operational-values test passes. - fix(fuzz):
fuzz-config-parsernow linkshttp_lib,observability_lib,core_lib, andplatform_libbecause config validation callshttp::rate_limit_policy_is_valid, andrate_limit.cpppulls in observability symbols.
Testing¶
- test(federation):
tests/unit/test_federation_runtime_callbacks.cppnow blocks a federation transaction sink and asserts unrelated runtime work can still acquireHomeserverRuntime::mutex, preventing the wide-lock drip-feed regression from returning. - test(federation): transaction validation and inbound federation tests now cover Matrix PDU/EDU count caps plus per-origin transaction/PDU/EDU throttling without incrementing remote trust failures.
0.10.31¶
Fixed¶
- fix(federation): inbound PDU ingestion no longer serializes all federation traffic behind a single
runtime.mutex— per-room striped locks now protect room-local ordering while independent rooms commit concurrently: the defaultpdu_sinkpreviously held the global runtime mutex for the entire auth, persistence, membership-update, and sync-notification path. Under a burst of 30–40 inbound federation events this forced every PDU to wait for the previous one's database commit, turning quick succession into a drip-feed even though SQLite/PostgreSQL backends open a fresh connection per transaction and could otherwise overlap. Fix:HomeserverRuntimenow owns 256 room-stripe mutexes;ingest_pdu_event()reserves a global stream-ordering token, then acquires only the stripe for the event'sroom_idwhile building the auth map, preparing the prepared-state update, applying in-memory rows, and updating membership, releasing that stripe for the actual database commit so commits for different rooms run in parallel.database::store_event_with_state()is refactored intoprepare_store_event_with_state(),commit_persistent_transaction(), andapply_store_event_with_state()to make the lock-free commit boundary explicit. - fix(federation):
ingest_pdu_event()now allocates a freshsync_stream_idfor every accepted PDU instead of re-readingpersistent_store.next_sync_stream_id: the previous read published the same counter value repeatedly (usually0for a fresh store), breaking sync-token advancement for federation-driven events and any test that checked the published sync surface advanced. - fix(federation):
ingest_pdu_event()no longer performs thesync_stream_idbackend write while holding both a room stripe and the globalruntime.mutex: both IDs are now reserved under the global mutex alone before the stripe is acquired. This removes the long database write from the per-room critical section, letting other events for the same room proceed and avoiding a broad contention window under ThreadSanitizer. - fix(ci): ThreadSanitizer CI job now preserves its live output and Meson test log as artifacts: the previous workflow only surfaced what GitHub Actions captured in the run log, which truncated before the timed-out scenario name and omitted
build-tsan/meson-logs/testlog.txt. Thetsanjob now tees stdout/stderr totsan-ci-log.txtand uploads that file plus the Meson logs withif: always(), so future hangs show exactly which scenario is stuck. - fix(federation-worker):
WorkerSupervisor::stop()no longer logs false-positive "Federation worker did not exit" warnings when the supervisor thread has already reaped the child:waitpidreturningECHILDis now treated as a successful reap, so the fallback SIGTERM/SIGKILL escalation is skipped instead of being directed at a process that no longer exists. - fix(federation-worker):
WorkerSupervisorno longer blocks its supervisor thread in a synchronouswaitpidwhile waiting for the child to exit: the previous blocking wait meantstop()could not join the supervisor thread until the worker process exited, so a TSan-instrumented worker that was slow to terminate (or stuck during teardown) delayed the wholeWorkerPool::stop()by the full child-exit time. The supervisor loop now polls withWNOHANGand a short sleep, sorunning_ = falsepromptly unwinds the loop andstop()proceeds to its own bounded reap/SIGTERM/SIGKILL sequence. - fix(federation):
ingest_pdu_event()no longer reports membership PDUs asinternal_errorafter the event has already been committed: ifupsert_membership()fails afterapply_store_event_with_state(), the event is already in the database and the accepted result has been published, so returning an error to the upstream server is incorrect and can cause duplicate retries. The function now logs a warning and keeps the accepted result, matching the fact that the PDU was already accepted. - fix(ci,test):
tests/integration/test_seccomp_sqlite_flow.cppno longer hangs CI when a blocked syscall is called in a retry loop under ThreadSanitizer: the seccomp test usesSECCOMP_RET_TRAPso the SIGSYS handler can report missing syscalls, but if SQLite/glibc retried the same blocked syscall (e.g.fchownwhen the child could not drop to a non-root uid), the handler kept trapping forever and the parent blocked onwaitpid. The handler now limits itself to 32 reports and then terminates the child withexit_group, so the test fails fast and names the offending syscall instead of consuming the full 1800s CI timeout.
Testing¶
- test(federation): new
tests/unit/test_federation_pdu_ingest_concurrency.cppexercises concurrentingest_pdu_event()calls across distinct rooms and asserts the resulting events/membership/stream-ordering state is correct. - test(federation):
tests/unit/test_federation_pdu_ingest_concurrency.cppno longer callsREQUIREfrom worker threads, which caused ThreadSanitizer to report races on Catch2's internal assertion counters; all envelopes and all post-join state checks are now built/verified on the main thread while workers only update an atomic accepted counter.
0.10.30¶
Fixed¶
- fix(federation-worker,ipc): WorkerPool now runs every worker-to-main ingest handler on a dedicated handler thread pool instead of inline on the
IpcChanneldispatch thread: the 0.10.29 reader/dispatch split stopped the reader thread from freezing, but the single per-channel dispatch thread still serialized everypdu_ingest,membership_ingest,edu_ingest,invite_ingest,otk_claim_ingest,user_devices_ingest,device_keys_query_ingest,profile_query_ingest,event_query_ingest, andsign_request. A slowpdu_ingest(e.g. persisting an event underruntime.mutexand waking sync notifiers) therefore parked the dispatch thread and delayed every later frame on that channel, including responses to unrelated outbound requests — the residual drip-feed on high-rate federation. Fix:WorkerPoolconstructs anet::ThreadPool(handler_pool_, sized fromfederation.worker.relay_threads) and submits each ingest handler to it, so the dispatch thread only classifies and enqueues frames. Slow ingestion now runs concurrently with later frames. - fix(federation-worker):
WorkerSupervisor::stop()and the supervisor restart path no longer deadlock with the IPC dispatch thread: both paths previously heldchannel_mu_while callingchannel_->stop(), which joins the dispatch thread; apdu_ingesthandler running on that thread can call back into the same supervisor'schannel_snapshot()(vianotify_room_changed()) and block on the same mutex. The stop/restart code now takes ownership of the channel under the lock and releases the lock before callingstop(), keeping the channel alive through astd::shared_ptrsnapshot so callers can finish safely. - fix(ci): ThreadSanitizer integration-test job no longer times out at Meson's default 600 s: TSan-instrumented binaries run slower than ASan, and the seccomp/SQLite fork-heavy integration scenario was being killed by the default timeout, producing a storm of
signal-unsafe call inside of a signalwarnings from Catch2's fatal signal handler as it tried to print a summary while handling the SIGTERM. Thetsanjob now setsMESON_TEST_TIMEOUT_MULTIPLIER: 3, matching the existingasan-ubsanjob. - fix(federation-worker):
WorkerPool::stop()now drainshandler_pool_before stopping worker supervisors: without this, in-flight main-side IPC handlers could still be running when their channel was closed, and tests that stopped the pool immediately after startup could hang during TSan-instrumented teardown while a handler thread raced a channel whose dispatch thread had already been joined. - fix(federation-worker):
WorkerSupervisor::stop()no longer waits indefinitely for the child process: the previous blockingwaitpid(worker_pid_, nullptr, 0)could hang forever if a TSan-instrumented worker child deadlocked or failed to exit. The supervisor now polls withWNOHANG, waits up torequest_timeout_seconds, then escalates toSIGTERM, waits again, and finallySIGKILLand a reaping wait. This keeps a stuck worker from blocking process shutdown or test teardown.
Testing¶
- test(ipc): new
IpcChannelscenario intests/unit/test_ipc_framing.cppmodels the main-side handler-pool fix: a deliberately slow request handler is offloaded to anet::ThreadPoolwhile a fast second request is also offloaded, proving the fast request replies before the slow one completes and that the dispatch thread is not serialized by slow work.
0.10.29¶
Fixed¶
- fix(ipc,federation-worker): federated messages no longer stall for ~60 seconds each and drip-feed in over minutes of origin-server retries —
IpcChannelnow routes frames on the reader thread and runs request handlers on a separate per-channel dispatch thread: the reader thread previously invoked the request handler inline for every inbound request/notification frame, so a handler that blocked on a lock froze all frame routing for the channel — including responses to the channel's own pendingsend_requestcalls sitting right behind the blocking frame. Two production deadlock cycles followed from this. (1) Worker side, on every relayed PDU: the worker's relay thread holds the workerruntime.mutexfor the whole ofhandle_federation_http_request()— including itspdu_sinkIPC round trip to main — while main'spdu_ingesthandler, after committing the event, sends aroom_syncnotification (whose worker-side handler needs that same worker mutex) immediately before thepdu_ingestresponse. The worker's reader thread picked up the notification first, blocked inline onruntime.mutex, and could never route the response one frame behind it; the relay thread waited out the full 60spdu_ingesttimeout, main'sWorkerPool::handle()503'd the transaction atrequest_timeout_seconds, and the origin server re-sent it on an exponential backoff — a flurry of messages between two users then trickled in one ~60s stall at a time even though main had already committed and sync-published each event on first receipt. (2) Main side, the mirror image: a client-server handler holding main'sruntime.mutexacross a worker-proxied outbound call (e.g. the remote/keys/queryfan-out, common in E2EE conversations) waits on anoutbound_http_responsethat main's reader thread cannot route when a concurrentpdu_ingestarrived first and blocked inline on that same mutex — both sides then sat out their full timeouts. Fix: request frames are queued and handled one at a time, in arrival order, by a dedicated dispatch thread per channel (preserving the previous serial-handling semantics), while the reader thread only wakessend_requestwaiters and enqueues — a blocked handler can no longer stall response delivery in either process. Additionally the worker now runsroom_syncreloads onlocal_poolinstead of the channel's dispatch thread, sincereload_roomneedsruntime.mutex, which an in-flight relayed transaction can legitimately hold for its full duration — parking the dispatch thread on it would delay every later queued request frame. Seedocs/architecture.md, "IPC reader/dispatch split".
Testing¶
- test(ipc): two new
IpcChannelscenarios intests/unit/test_ipc_framing.cpp: a regression test that models the drip-feed deadlock directly (a request handler needing a mutex held by a thread blocked insend_request, with the notification frame arriving ahead of the response frame — times out on the pre-fix inline dispatch, passes with the dispatch thread) and an ordering test pinning that request frames are still handled strictly in arrival order with the new dispatch thread.
0.10.28¶
Fixed¶
- fix(federation,e2ee): remote users joining a Merovingian-hosted encrypted room now receive local device-list updates immediately: the
send_joinmembership acceptor now broadcastsm.device_list_updateEDUs for existing local members in the room to the joining user's server, so the remote client can query/use current Merovingian device keys before sending its first encrypted reply. - fix(federation-worker,e2ee): EDU-only
/sendtransactions no longer queue behind shard-0 worker traffic: after X-Matrix verification in main, transactions with no PDU room ID are handled in main directly instead of being routed to worker shard 0 and relayed back to main'sedu_sink, reducing delayed or timed-outm.direct_to_devicekey-share delivery.
Documentation¶
- docs(federation-worker): documented the EDU-only
/sendmain-process bypass and narrowed relay-pool wording to PDU-bearing transactions.
Testing¶
- test(federation,e2ee): wire the remote-join device-list fanout unit test's dispatch worker to the persistent store it asserts against and cover the guard that skips login-only devices without uploaded device keys.
0.10.27¶
Documentation¶
- docs(architecture): align the architecture overview with the current implementation and Matrix v1.18 references: refreshed the module list, runtime model, federation-worker consistency model, implemented API summaries, and spec citations in
docs/architecture.md.
0.10.26¶
Fixed¶
- fix(federation-worker): a federation worker shard could go fully unresponsive for roughly 30 seconds at a time with no crash or supervisor restart logged, coinciding with a burst of
503s a remote peer typically retried successfully: everymerovingian-fed-workerprocess ran a single fixed-size thread pool (federation.worker.threads, shipped default 4) for two jobs at once — dispatching every incomingfed_requestforwarded from main, and, inside that same dispatch, blocking synchronously on an IPC round-trip back to main whenever the request neededpdu_sink,edu_sink,membership_acceptor,invite_handler, or one of the query-provider relays (profile_query_provider,device_keys_query_provider,user_devices_provider,event_query_provider,one_time_keys_claim_provider— see the 0.10.19-0.10.24 relay fixes above). Those calls wait on main across a singlestd::recursive_mutex runtime.mutexshared by every relayed operation from every shard, with timeouts of 10-60 seconds. If enough of them landed on one shard concurrently to occupy allthreadspool workers at once, the pool's unbounded queue had nowhere to run a newly arrivedfed_request— even one that would never need to call main at all, such as astate/backfill/state_idsread answered entirely from the worker's own local snapshot. The shard then appeared fully hung until the backlog drained, at which pointWorkerPool::handle()'s ownfederation.worker.request_timeout_seconds(shipped default 30s) elapsed and returned a503to the remote peer. The same mechanism plausibly also explains intermittent to-device delivery gaps investigated this cycle (see Testing below): an EDU's ownedu_sinkcall queued behind this backlog can complete so late that nothing is still listening for it. Fix:worker_event_loop.cppnow runs two pools —local_pool(existingthreads) for endpoints answerable entirely from the worker's own local snapshot (make_join/make_leave/make_knock,backfill,query/directory,state,state_ids,get_missing_events,hierarchy, and the no-opsend_edustub), and a newrelay_pool(federation.worker.relay_threads, default 32) for the ten endpoints that can call main (transaction,send_join/send_leave/send_knock,invite,query_profile,query_keys,claim_keys,query_user_devices,query_event) plus everyoutbound_http_requestmessage, which blocks on a remote server's response instead. The classification is a new pure function,federation::federation_endpoint_requires_main_relay(FederationEndpoint), applied to the existing route match before a thread is committed. New scenario intests/unit/test_federation_transactions.cppiterates every entry infederation_routes()and asserts each lands in its expected pool bucket, so a future endpoint added without an explicit classification is caught immediately (the switch has nodefault:case). Seedocs/architecture.md, "Federation worker relay pool separation". - fix(ci): the Fedora, RHEL, and OpenSUSE RPM package jobs started failing after the 0.10.25 merge — the
.rpmbuild itself succeeded every time, but theAttest ... RPMstep (actions/attest-build-provenance@v2) failed withInternalError: error fetching tlog entry - (404) Not Found: Sigstore's Rekor transparency log had not finished replicating the entry the action itself just wrote by the time it immediately tried to read it back — an external infrastructure race, not a build or packaging defect. Fix: each of the threeAtteststeps in.github/workflows/packages.ymlnow retries up to twice with a short delay (15s, then 30s) before giving up for real, usingcontinue-on-errorplussteps.<id>.outcomegating — the same repo-native retry pattern already used for thenetbsd-pkg-retryjob and thezypperinstall loop in the OpenSUSE job, applied at step granularity since re-running the whole build is unnecessary. The final attempt in each chain has nocontinue-on-error, so a genuine, persistent failure still fails the job.
Testing¶
- test(federation,sync): investigated the ongoing intermittent E2EE key-share decrypt failures reported on
pong.ping.me.ukand ruled out three suspects against the real code paths rather than test-only shortcuts: a large, deeply nested Olm-encryptedm.direct_to_deviceEDU was routed throughhandle_federation_http_request()(the real production/worker entry point, as opposed tohandle_local_http_request()'s federation branch, which looks similar but is a test-only shortcut that never callswire_federation_callbacks_impl()) intests/integration/test_federation_inbound_flow.cpp; the same realistic EDU was sent over a real encryptedipc::IpcChannelpair (real AF_UNIX socketpair, real AEAD framing) intests/integration/test_federation_worker_flow.cpp, proving a large nested payload survives the actual worker/main transport; andtests/unit/test_sync_handler.cppwired federation callbacks the waymain.cppdoes at startup and calledhandle_edu_ingest_request()(what a worker's IPC relay lands on) while a client was parked in a long-poll, proving the sync notifier wakes exactly as it does for a local to-device send. All three passed, ruling out large-payload parsing, the IPC transport, and sync-notifier wiring as the cause; this investigation is what led to identifying and fixing the federation worker thread-pool starvation bug above.
0.10.25¶
Diagnostics¶
- diag(federation):
handle_inbound_federation_request()'s own logging (request.received,transaction.accepted) has never been observed in production despite clear downstream evidence that it executes —event_state.persisted(logged on main's side when a worker relays a PDU viapdu_ingest) fires reliably for real transactions, which is only reachable by that function callingpdu_sinkpartway through its body, yet neither its first line nor its final log line ever appears, with no exception (thread_pool: event=worker.exceptionnever fires on either process's thread pool), no worker crash/restart, and no seccomp syscall denial (write/writev/futex/cloneare all allowed in the worker hardening profile) found to explain it: sincehandle_inbound_federation_requestruns entirely inside the federation worker's own process when a room-scoped request is relayed there, and its logging is the one piece of visibility this investigation could not otherwise obtain,WorkerPool::handle()— which runs on main, whose own logging has been reliable throughout every incident investigated so far — now logs the raw status, body length, and a short body prefix of every reply a worker sends back, immediately after deserializing it and before returning it to the caller. This does not fix a known bug; it exists to determine, from a call site proven trustworthy, whether the worker's reply actually originated fromhandle_inbound_federation_request's normal success path (in which case the worker's own logging has a real, separate gap worth fixing next) or from somewhere else entirely.
0.10.24¶
Fixed¶
- fix(federation,security): the federation worker's
user_devices_provider,device_keys_query_provider, andprofile_query_providerdecided their answers from a per-process in-memory snapshot hydrated once at worker startup, with no mechanism to ever refresh it, causing a remote server's E2EE key-share to silently fail forever: unlike the write-relay hooks (pdu_sink,membership_acceptor,edu_sink,invite_handler) and the split-brain hook (one_time_keys_claim_provider), these three are pure reads backingGET /_matrix/federation/v1/user/devices/{userId},POST /_matrix/federation/v1/user/keys/query, andGET /_matrix/federation/v1/query/profile— none of which carry a room ID, so unlikestate_query_provider/state_ids_query_provider/backfill_provider/missing_events_query_provider(kept fresh bynotify_room_changed()/reload_room(), see "Federation worker room staleness") there is no per-room notification channel that could ever refreshPersistentStore::device_keys/PersistentStore::profileson a worker. A device key uploaded, or a profile changed, through main's client-server API after a worker started is therefore permanently invisible to that worker. Reproduced against a real two-homeserver federation (matrix.ping.me.uk→pong.ping.me.uk,federation.worker.shards=2, the shipped example config):matrix.ping.me.ukqueriedGET /_matrix/federation/v1/user/devices/@james:pong.ping.me.ukto learn which device to target anm.room_keyto-device share at, got a spurious404 M_NOT_FOUNDfrom a worker whose device-key snapshot predated the device's key upload, and never sent the room key — the recipient's client loggedCan't find the room key to decrypt the event, withheld code: Nonefor every message under that session, with nothing in the server's own logs indicating a failure, since the transaction carrying the encrypted PDU itself was still accepted and delivered normally. Fix:worker_event_loop.cppnow overrides all three providers the same way it overridesone_time_keys_claim_provider— serializes the request (a bareuser_id, or the raw federation request body fordevice_keys_query_provider) into a newuser_devices_ingest/device_keys_query_ingest/profile_query_ingestIPC frame and calls main, rather than deciding locally;worker_pool.cpp'shandle_user_devices_ingest_request()/handle_device_keys_query_ingest_request()/handle_profile_query_ingest_request()(free functions alongside the other*_ingest_requesthandlers, same test-seam reason) receive them on main's side and invoke main's own unmodified providers underruntime.mutex. New scenarios intests/integration/test_federation_worker_flow.cppseed a device key and a profile on main's own store, drive each handler with a request shaped like a real worker's ingest frame, and assert the response reflects main's data rather than an empty worker-local snapshot. Seedocs/architecture.md, "Federation worker user/device/profile/event query relay". - fix(federation):
event_query_provider(GET /_matrix/federation/v1/event/{eventId}) has a related but structurally different bug from the three above — its path carries no room ID either, so unlikestate/state_ids/backfill/get_missing_eventsit can never be sharded by room and always lands on shard 0 regardless of which shard actually owns the event's room: the same class of routing-alignment problemquery/directoryalready has (see "Shard routing must key on the same room ID string as the notification"), not the "no notification channel exists at all" problem the three providers above have —PersistentStore::eventsfor a room hosted on a shard other than 0 is never refreshed on shard 0, sincenotify_room_changed()hashes and delivers to the room's actual owning shard, not shard 0 unconditionally. A real routing fix would need an event-ID-to-room-ID index reachable before shard selection, which does not exist today. Fix: rather than build that index,event_query_provideris relayed through main via a newevent_query_ingestIPC frame the same way the three providers above are — main receives every event viapdu_sinkregardless of which shard accepted it, so answering from main's store is correct no matter which shard the request lands on.worker_pool.cpp'shandle_event_query_ingest_request()follows the same free-function/test-seam pattern as the other*_ingest_requesthandlers. New scenario intests/integration/test_federation_worker_flow.cppseeds an event on main's own store and asserts the relayed response contains it. Seedocs/architecture.md, "Federation worker user/device/profile/event query relay".
0.10.23¶
Fixed¶
- fix(media,security):
fetch_remote_media_live()fabricatedscanner_clean=true/decoder_marked_safe=truefor every federated media fetch, bypassing the AV-scanner gate for attacker-controlled content wheneversecurity.media.remote_fetch_enabledis on: the remote-fetch path is the only untrusted-content boundary in the media repository — unlike a local upload, a federated origin server has no accountable local identity behind it — yet it reported the same "clean" verdict as a locally authenticated upload with no real scan ever having occurred (Merovingian does not run an AV engine for any media source today; seedocs/media-repository.md). Fix:remote_req.scanner_cleanis now honestly reported asfalse(no scan happened), and a newMediaAcceptancePolicy(allow/allow-after-scan/quarantine/deny) governs the final disposition, configured independently for local uploads (security.media.local_upload_policy, defaultallow-after-scan, preserving prior behaviour) and remote fetches (security.media.remote_fetch_media_policy, defaultquarantine, since federated content is never actually scanned).decoder_marked_safeis deliberately lefttruefor remote content:unsafe_decoders_disabledhas no config knob today, so flipping it would hard-reject every remote fetch in every deployment by default rather than fail safely — tracked as a follow-up alongside real AV-scanner integration. New scenarios intests/unit/test_media_security.cpp(evaluate_media_upload's four-policy behaviour) andtests/unit/test_media_repository.cpp(remote fetch quarantined by default vs. local upload accepted by default under identical scanner-clean input). - fix(media,security): the outbound federation media download URL omitted the
{serverName}path segment the spec requires and never percent-encoded either segment:fetch_remote_media_live()built/_matrix/media/v3/download/{mediaId}instead of/_matrix/media/v3/download/{serverName}/{mediaId}(server-server-api.md#get_matrixmediav3downloadservernamemediaid), so a media ID or origin server name containing reserved characters could be misread as an extra path segment or a different route on the resolved host. Fix: the URL construction is extracted intoremote_media_download_url()(homeserver/media_service.hpp), which builds the correct two-segment path with bothorigin_serverandmedia_idpassed throughcore::percent_encode_path_component(). Newtests/unit/test_homeserver_media_service.cpppins the URL shape and the encoding of reserved characters. - fix(http,security): trusted-proxy
X-Forwarded-Forrate-limit keying trusted any non-empty value verbatim with no IP validation: when the direct peer was a configuredserver.trusted_proxiesentry, the client-server rate limiter used the leftmostX-Forwarded-Forvalue as the rate-limit key without checking it was a syntactically valid IP address, letting an attacker (or a misconfigured proxy that fails to overwrite an inbound header) rotate through malformed pseudo-IP strings to mint a fresh bucket per request and defeat per-IP rate limiting on/login,/register, and every other endpoint. Fix: the candidate is now validated with the newfederation::ip_address_is_valid()(strictinet_pton-based IPv4/IPv6 literal check) before being trusted; a missing or malformed value falls back to the direct peer address. Newtests/unit/test_client_server.cppscenario asserts two malformed X-Forwarded-For values from the same trusted proxy share the direct-peer bucket instead of minting independent ones. - fix(media,security): admin media quarantine/release/remove routes accepted unsanitized media IDs from the raw path suffix: unlike the download/thumbnail routes (
local_media_download_parts()), the/_merovingian/admin/media/{quarantine,release,remove}routes passed the raw path suffix directly as the media ID with no validation, so a request like.../remove/<id>?reason=xtreated the query string as part of the ID — no record matched it, and the intended object was silently left untouched instead of acted on. Fix:admin_media_id_from_suffix()(local_http_router.cpp) strips any query string and rejects an empty ID, an embedded/, a..traversal sequence, or an embedded space before the ID reaches the admin action; a400is returned instead. Newtests/integration/test_media_repository_security.cppscenario covers a trailing query string (correctly stripped, object still acted on), a path-traversal sequence, and an empty ID.
Documentation¶
- docs(media,security): made explicit, in the places an operator is most likely to read them, that AV scanning can never inspect encrypted-room media: the new
MediaAcceptancePolicykeys risked implying a scanner verdict is meaningful for all media, when in factapplication/octet-stream(E2EE attachments encrypted client-side before upload) is opaque ciphertext the server never holds the key for — no configuration, proxy placement, or future scanner integration can change that without breaking end-to-end encryption's confidentiality guarantee. Added aWARNINGblock toconfig/merovingian.conf.example's media section, a new "Encrypted media is never scannable" section todocs/media-repository.md, a leading paragraph to the media section ofdocs/configuration.md, and a cross-reference fromdocs/architecture.md's media-security summary line.
0.10.22¶
Testing¶
- test(federation): the 0.10.19-0.10.21 worker/main relay fixes (
membership_ingest,edu_ingest,invite_ingest,otk_claim_ingest) only had happy-path integration coverage — every existing scenario assertedaccepted:true/status:200, none exercised the fail-closed branches those handlers actually contain: added eight new scenarios totests/integration/test_federation_worker_flow.cpp, driving eachhandle_*_ingest_request()free function directly with the same wire formatworker_event_loop.cppproduces (the same test-seam pattern the original scenarios established).handle_membership_ingest_request: relaying asend_joinfor a room main has never stored now asserts the existing{false, 404, "room not found"}branch and that no membership row is written; calling it withmembership_acceptorunwired asserts the501fallback rather than assuming it can't happen.handle_edu_ingest_request: am.direct_to_deviceEDU missing the requiredmessagesfield now assertsrejected_invalidand that nothing reachesto_device_messages; an unwirededu_sinkassertsrejected_invalidwith a"edu_sink not wired"reason instead of crashing on an emptystd::function.handle_invite_ingest_request: inviting a user this server does not host now asserts the404 "invited local user not found"branch and that no membership/invite row is written; an unwiredinvite_handlerasserts501.handle_otk_claim_ingest_request: the actual split-brain property 0.10.21 fixes is pinned directly — two consecutive claims for the same(user, device, algorithm)now assert the second returns no key material, since the first claim must consume it from main's single store; a device with no stored one-time or fallback key asserts an emptyone_time_keysobject rather than an error. A sharedipc_escape_json_string()helper replaces the escaping lambda duplicated across scenarios. No production code changed; all new and pre-existing scenarios in the file pass (94 assertions, 12 test cases for[membership],[edu],[invite],[otk]alone).
0.10.21¶
Fixed¶
- fix(federation): the federation worker's
invite_handlerpersisted a federated invite's membership row, invite metadata, and event only into whichever process ran it — never relayed to main, the same class of gap 0.10.19 fixed formembership_acceptor:invite_handlerwas not among the hooks the worker overrides (pdu_sink,membership_acceptor,edu_sinkwere), soPUT /_matrix/federation/{v1,v2}/invite— room-scoped, so handled by a worker wheneverfederation.worker.shards >= 1(the shipped example config) — ran the default implementation (local_http_router.cpp) unmodified, writingupsert_membership/database::upsert_invite/database::store_event_with_statestraight into that worker's ownPersistentStore. Since real clients only ever/syncagainst main, and nothing else ever re-syncs invite data into main's store, a remote server inviting one of this server's local users to a room hosted elsewhere was silently swallowed: the invite never appeared in the invited user's own/sync, with no error anywhere. Fix:worker_event_loop.cppnow overridesinvite_handlerthe same way it overridesmembership_acceptor— serializes theInviteRequestinto a newinvite_ingestIPC frame and calls main instead of persisting locally;worker_pool.cpp'shandle_invite_ingest_request()(a free function alongsidehandle_membership_ingest_request(), same test-seam reason) receives it on main's side and invokes main's own unmodifiedinvite_handlerunderruntime.mutex. New integration scenario intests/integration/test_federation_worker_flow.cppdriveshandle_invite_ingest_request()directly and asserts the invite's membership row and invite metadata land in main's ownPersistentStore. Seedocs/architecture.md, "Federation worker invite relay". - fix(federation,security): the federation worker's
one_time_keys_claim_providerdecided one-time-key availability from a per-process in-memory snapshot, risking a key being handed out twice (breaking Olm's single-use guarantee) and going permanently stale as fresh keys were uploaded through main:database::claim_one_time_keysearchesPersistentStore::one_time_keys— an in-memory vector hydrated once at worker startup — before issuing aDELETE; that vector is never invalidated by a different process's write to the same underlying table.POST /user/keys/claimis non-room-scoped and lands on shard 0 in steady state, butWorkerPool::handle()falls back to running in-process on main when the owning shard is unhealthy — if shard 0 already claimed a key and main's own copy hasn't independently learned it's gone, a later claim falling back to main can return the same one-time prekey a second time, undermining the forward-secrecy guarantee a one-time key exists to provide. The same divergence also caused key claims to dry up in the other direction: keys uploaded through main after a worker started were invisible to that worker's frozen snapshot. Fix: rather than patch the claim-and-delete to be atomic at the database layer (which would still leave main's own cache diverged from a worker's writes),one_time_keys_claim_provideris now relayed through main entirely, the same as the other hooks —worker_event_loop.cppserializes the raw claim request body into a newotk_claim_ingestIPC frame;worker_pool.cpp'shandle_otk_claim_ingest_request()invokes main's own unmodified provider underruntime.mutex. New integration scenario intests/integration/test_federation_worker_flow.cppseeds a one-time key, drives the handler directly, and asserts both that the response echoes the claimed key and that the key is gone from main's own store afterward. Seedocs/architecture.md, "Federation worker one-time-key claim relay". - fix(federation): ordinary messages and state changes never refreshed a federation worker's room snapshot — only the 7 membership-mutating room_service calls did, and
pdu_sink's worker override deliberately never writes to the worker's own store either — so worker-servedbackfill/event/state/state_ids/get_missing_eventsqueries for an active room could silently omit every message sent since its last membership-triggered reload:send_event()(room_service.cpp) was never among the calls that invokenotify_room_changed(), and nothing pushed an inbound-relayed message (via a worker'spdu_sinkIPC call) back down to the relaying worker's own snapshot either. Fix:send_event()now also callsnotify_room_changed(runtime, room_id), matching the placement convention of the existing 7 call sites;worker_pool.cpp'spdu_ingestIPC handler now callsnotify_room_changed(env.room_id)after a successfulpdu_sinkcommit, pushing the just-committed event back to whichever shard owns the room (in practice the same worker that made the call, since shard selection is a pure function ofroom_id). Newsend_event notifies the federation worker when an ordinary message is sentscenario intests/unit/test_homeserver_room_service.cppextends the existingtest_room_changed_log-pinned contract to this eighth call site. Seedocs/architecture.md, "Federation worker room staleness".
0.10.20¶
Fixed¶
- fix(federation): the federation worker's
edu_sinkwas a hard no-op that silently dropped every inbound EDU it handled — includingm.direct_to_device, the transport for E2EE megolm room-key shares — while logging them as dispatched rather than dropped:worker_event_loop.cppsetruntime.federation.edu_sink = {};unconditionally with the stated reasoning "EDUs are ephemeral and acceptable to drop." True form.typing/m.receipt/m.presence, but not form.direct_to_device(carriesm.room_key/m.room_key.withheldto-device messages) orm.device_list_update. Worse,inbound_request.cpp's transaction loop counts an EDU with noedu_sinkinstalled asedus_dispatched, notedus_dropped— thetransaction.accepteddiagnostic and the200returned to the sending server gave no indication anything was lost. Reproduced against a real two-homeserver federation (matrix.ping.me.uk→pong.ping.me.uk,federation.worker.shards=2, the shipped example config): a megolm room-key share sent as anm.direct_to_deviceEDU landed on a worker shard and vanished; the recipient's client (matrix-sdk-crypto0.18.0) loggedCan't find the room key to decrypt the event, withheld code: Nonefor every subsequent message encrypted with that session, and itsPerSessionKeyBackupDownloaderfallback also failed, since the key was never received by any recipient device in the first place — not even anm.room_key.withheldnotice, since that's carried as an EDU too and would have been dropped identically. This is the same class of bug as the 0.10.19membership_acceptorfix and the 0.10.14–0.10.18 room-staleness fixes, but for the one remaining unrelayed hook. Fix:worker_event_loop.cppnow overridesedu_sinkthe same way it overridespdu_sink/membership_acceptor— serializes theInboundEduEnvelope(edu_type, origin, content_json) into a newedu_ingestIPC frame and calls main instead of handling (or dropping) it locally;content_jsonis embedded as an escaped JSON string viaipc::ipc_json_str, the same techniquepdu_ingest'sjsonfield already uses, so nested content containing a literal"type"key (asm.room_key.withheldcontent does) cannot be confused with the outer frame's own"type"field. A newhandle_edu_ingest_request()(worker_pool.cpp, exposed as a free function alongsidehandle_membership_ingest_request()for the same test-seam reason) receives the relayed request on main's side, reconstructs the envelope viafederation::parse_inbound_edu_envelope(), and invokes main's own unmodifiededu_sinkunderruntime.mutex— no separatesync_notifier->publish()call is needed, since every case inside main'sedu_sinkalready publishes on its own success path. All EDU types are relayed uniformly (not justm.direct_to_device), so a future EDU type added toclassify_edu_typewithout a matching drop-list update can't silently regress this again. New integration scenario intests/integration/test_federation_worker_flow.cppdriveshandle_edu_ingest_request()directly with anm.direct_to_devicerequest shaped exactly like a real worker'sedu_ingestframe and asserts the room-key share lands in main's ownPersistentStore::to_device_messages— the same test-seam pattern the 0.10.19 membership fix established, for the same reason (a real end-to-end worker-subprocess round trip needs a live, network-resolvable remote signing key to clear the federation-policy gate ahead ofedu_sink, orthogonal infrastructure this bug isn't about). Seedocs/architecture.md, "Federation worker EDU relay".
0.10.19¶
Fixed¶
- fix(federation): a remote user's federated join/leave/knock — accepted correctly by a federation worker — was invisible to the main process's own room state, so every subsequent message from that member was rejected with
"sender is not joined to the room":merovingian-fed-workerruns its own fullHomeserverRuntime, and per its own documented design ("It does NOT write events — accepted PDUs are sent to main via pdu_ingest IPC and main commits them with the authoritative counter",worker_event_loop.cpp), regular/sendtransaction events already relay throughpdu_sinkto main for persistence.membership_acceptor— the handler backingsend_join/send_leave/send_knock, wired by the same sharedwire_federation_callbacks_impl()used forpdu_sink's default implementation — was never given the same treatment: it persisted straight intort->database.persistent_store, which inside a worker process is that worker's own local, in-memory-cached store, completely bypassing main. So when a remote server joined a room viasend_join(processed inside a worker), the join was durably written to the worker's own view of the database, but main's ownPersistentStore— the onepdu_sinkauthorizes every later/sendmessage against — never learned the member existed. Reproduced against a real two-homeserver federation (matrix.ping.me.uk→pong.ping.me.uk): the remote user's join succeeded (visible via a client-side sync), but their very next message was silently dropped byevent_auth's step-10 "sender must be joined" check, logged only as aDEBUG-levelauthorization.rejectedline with no error-level signal anywhere. This is a distinct bug from the earlier 0.10.14–0.10.18 federation-worker-staleness/shard-routing fixes: those were about a worker's view of a room going stale relative to main, or a room-sync notification landing on the wrong shard; this is the reverse direction — a worker unilaterally accepting a write that main, the authoritypdu_sinkchecks against, never receives at all. Fix:runtime.federation.membership_acceptoris now overridden inside the worker process (worker_event_loop.cpp), mirroringpdu_sink's existing override exactly — it serializes the accepted endpoint (send_join/send_leave/send_knock) and the inbound PDU envelope into a newmembership_ingestIPC frame and calls main over the same channel, rather than writing locally. A newhandle_membership_ingest_request()(worker_pool.cpp, exposed as a free function alongside the existingfederation_worker_shard_for()specifically so it has a test seam independent of the IPC machinery) receives the relayed request on main's side, invokes main's own (unmodified)membership_acceptorunderruntime.mutex, and returns theMembershipAcceptResult— including theauth_chain/statesnapshot thesend_joinresponse body requires — back across the channel. Unlikepdu_ingest's handler, this one does not need to separately callsync_notifier->publish(): the defaultmembership_acceptorimplementation already does that itself on success. New integration scenario intests/integration/test_federation_worker_flow.cppdriveshandle_membership_ingest_request()directly with a request shaped exactly like a real worker'smembership_ingestframe and asserts the join lands in main's ownPersistentStore(state and membership rows) — a real end-to-end round trip through a worker subprocess would additionally have to clear the unrelated "remote is unknown" federation-policy gate ahead ofmembership_acceptorininbound_request.cpp, which needs a live, network-resolvable remote signing key; this file's other worker-pool scenarios don't clear that gate either, and asserting onlystatus != 503for all of them was a known, documented test gap closed only partially here. Seedocs/architecture.md, "Federation worker room staleness".
0.10.18¶
Fixed¶
- fix(federation): inbound
make_join/state/send_join/etc. against a room that genuinely exists locally could still 404 from the federation worker whenfederation.worker.shardsis configured above the default of 1:federation_worker_room_id_from_request()extracted the room ID straight from the raw HTTP path segment (e.g./_matrix/federation/v1/make_join/%21abc:example.com/...) without percent-decoding it, then hashed that raw string viafederation_worker_shard_for()to pick which worker shard should handle the request. Every write path that keeps a shard's localPersistentStorein sync —notify_room_changed(), called fromcreate_room/join_room/leave_room/invite_user/ban_user/kick_user/unban_user— instead notifies using the plain, already-decoded room ID (!abc:example.com), since that's the form stored internally and never touches a URL.%21abc:example.comand!abc:example.comhash to different shards whenevershards > 1, so the room-sync notification landed on a different worker than the one that would later receive the request for that room: the request-serving shard's local store never learned the room existed, andhandle_make_membership()correctly (from its own perspective) returned404 M_NOT_FOUND, reproducing as a permanent "Failed to make_join via any server" from the remote server no matter how many times it retried. Reproduced against a real two-homeserver federation (pong.ping.me.uk→matrix.ping.me.uk), where the room was demonstrably resident and the invite had already been delivered successfully.room_id_from_path_target()now percent-decodes the extracted segment viacore::percent_decode_path_component()before it is returned, so shard selection uses the same canonical room ID string on both the write (notify_room_changed) and read (FederationProxy::handle) sides. This affects every room-scoped federation endpoint routed throughfederation_worker_room_id_from_request(), not justmake_join.federation.worker.shards=2is the shipped example config (config/merovingian.conf.example), so any deployment following it was exposed. New scenario intests/unit/test_federation_request_routing.cpppins that a percent-encoded room ID (%21room%3Aexample.com) in amake_joinpath decodes to the same room ID a plain, unencoded path would extract.
Testing¶
- test(federation): the sharding test suite never asserted the invariant that actually broke above, and no test anywhere fed a percent-encoded room ID through the routing path — both gaps are why this bug shipped unnoticed:
tests/integration/test_federation_worker_flow.cpp's existing "Room-scoped federation requests are routed to the correct shard" scenario only checked that each of two room IDs landed on some valid shard index (< shards), never that it was the same shardnotify_room_changed()would use for that room — the actual property that matters, since a wrong-but-valid shard index is indistinguishable from a correct one by that check alone. It's strengthened with a newWHENthat spins up a real two-shard worker pool and asserts a request-derived room ID (plain and percent-encoded) matches, string-for-string, the plain room IDnotify_room_changed()hashes directly, plus the resulting shard indices agree — string equality first because with only a couple of shards two different strings can coincidentally land in the same bucket, which would let a real mismatch slip past a shard-only comparison undetected (caught during development: an earlier version of this same test using only shard-index equality passed by 50/50 chance against the reintroduced bug). Verified against both states: fails deterministically with thepercent_decode_path_componentfix reverted, passes with it restored. Every unit-test scenario intests/unit/test_federation_request_routing.cppfor room-ID extraction previously used an already-decoded string as input, so the missing decode step had no unit-level test surface to fail against either;tests/unit/test_federation_request_routing.cppnow exercises percent-encoded room IDs across all thirteen v1 and five v2 room-scoped endpoint prefixes (previously onlymake_join/send_joinwere covered), not just the two originally added alongside the fix above. - fix(federation): removed a dead, misleading
room_endpoint_prefixes()entry forGET /_matrix/federation/v1/query/directorythat could never match a real request: per spec (server-server-api.md#get_matrixfederationv1querydirectory),room_aliasis a query parameter (?room_alias=...), not a path segment — confirmed againstinbound_request.cpp's own handler, which reads it viaquery_param_value(). The path-segment-style prefix"/_matrix/federation/v1/query/directory/"therefore never matched any real request's shape and was dead code; it's removed, and this endpoint now explicitly documents that it falls through to shard 0 like any other non-room request, matching its actual (still incomplete) behavior. Fixing the routing extraction alone would not have made this endpoint correct: the room alias and the room_idnotify_room_changed()partitions by are unrelated strings that hash to essentially independent shards, anddatabase::reload_room()does not syncstore.room_aliasesper-room today regardless of which shard a request lands on. Properly sharding room-alias federation queries needs a real design decision (route to shard 0 and replicateroom_aliasesto every shard, resolve the alias in the main process before forwarding, or a dedicated non-sharded alias index) — tracked as a follow-up, documented indocs/architecture.md's "Federation worker room staleness" section ("Known gap:GET /query/directorycannot be sharded by this scheme at all"). The corresponding unit test scenario is updated to assert the (accurate, if still limited) current behavior instead of asserting only "a room ID was extracted," which was true but did not reflect the real, still-broken-for-shards>1state.
0.10.17¶
Fixed¶
- fix(sync):
rooms.leave.<room_id>.timelinein the/syncresponse was always an empty events array, so a real client (Element/matrix-js-sdk) never actually saw itself leave even though 0.10.16 already fixedstream_ordering/sync_notifierand the server's own logs showedleave_count=1delivered correctly: confirmed against Element's own network/console log —POST /leavereturns200in 40ms, the in-flight long-pollGET /syncwakes and returns200with the room correctly keyed underrooms.leave, and the room still never disappeared from the client's room list. Per spec,rooms.leave.<room_id>.timelineis "the timeline of messages and state changes in the room up to the point when the user left" — matrix-js-sdk derivesroom.getMyMembership()by processing that timeline's state events, not merely from the room_id being present as a key, so an always-empty timeline gave the client nothing to act on. Newbuild_leave_timeline_events_array()(client_server.cpp) looks up the user's currentm.room.memberstate event for the room (authoritative even on an idempotent repeat/leave, sincestore.stateis upserted in place by every real transition, including kicks and bans) and includes it as the room's sole timeline event, matching what real homeservers send. Strengthened the existingtest_client_server.cppleave-sync scenario to assert the timeline actually contains anm.room.member/membership: leaveevent, not just the room_id key.
0.10.16¶
Fixed¶
- fix(sync,federation): a repeat
/leavecall on a room the caller already left server-side never healed a client stuck seeing it as joined:leave_room's idempotent "already left" branch (hit when the membership row is not"join"/"invite") returned200without callingpersist_membership_transition, so it never advancedstream_orderingor notifiedsync_notifier/the federation worker. Reproduced from a real server's logs, right after upgrading to 0.10.15: a client's earlier leave had flippedmembershipto"leave"correctly, but under the pre-0.10.15 bug itsstream_orderingstayed frozen, so/syncnever told the client it had left; the client, still believing it was joined, retried/leave— and that retry hit the idempotent no-op path, which (correctly, per spec, onmembershipalone) did nothing further, leaving the row permanently stuck even across the restart onto the fixed build. A repeat/leavecall is itself evidence the caller's view is stale, so the idempotent branch now also allocates a freshstream_ordering, persists it viastore_or_update_membership(membership value unchanged), publishes tosync_notifier, and callsnotify_room_changed()— self-healing rows left in this state by the 0.10.15-era bug without requiring a manual database repair. New regression scenario intests/unit/test_homeserver_room_service.cpppins that a secondleave_roomcall advancesstream_orderingpast the first and re-notifies the federation worker.
0.10.15¶
Fixed¶
- fix(database):
update_membership()silently droppedstream_orderingon every membership transition, freezing/sync's only signal that a membership row changed:POST /leave(and every other membership transition on a pre-existing row — invite, kick, ban, rejoin) went throughstore_membership()first, which only succeeds on a brand-new row; an existing row instead fell through toupdate_membership(), whoseUPDATE membership SET membership = $3 ...never touched thestream_orderingcolumn, and the in-memoryPersistentMembership::stream_orderingwas left untouched too. The row'smembershipvalue flipped correctly, but itsstream_orderingstayed pinned at whatever it was on first insert (frequently0), so any latersince-token comparison against it (joined_membership_changed_since, and therooms.leaveinclusion check below) could never see the change as "recent."update_membership()now takes astream_orderingparameter, persists it in the sameUPDATE(stream_ordering = $4), and writes it to the in-memory row; all nine call sites (room_service.cpp,local_http_router.cpp) now pass the same counter value used for the correspondingstore_membership()attempt. - fix(sync): a room the caller just left never disappeared from their client, because
/syncomitted it fromrooms.leaveunless the (rarely set)include_leavefilter was true: reproduced from a real server's logs —POST /leavereturned200androom.leave.accepted, but the room stayed in the client's list forever, and a repeat/leavealso silently no-op'd (membership was already"leave"server-side). Per spec, a room left before the sync window began is correctly omitted unlessinclude_leave: true— but a leave/kick/ban that happens within the current window is a state change the client must be told about regardless of that flag, since most clients (matrix-js-sdk, Element) never set it.rooms.leavein the incremental-sync path now also includes a"leave"membership whenmembership.stream_ordering > since_ordering, i.e. it changed after the caller's last sync; already-known leaves (older thansince) remain gated behindinclude_leaveas before. Depends on theupdate_membershipfix above — without it,stream_orderingnever actually advances on the leave, so this check alone would not have been sufficient. - fix(federation):
invite_user/ban_user/kick_user/unban_usernever notified the federation worker of the membership change, so a remote user invited to an existing, already-resident room could get a permanent502 Failed to make_join via any server/404frommake_join: the 0.10.14 federation-worker-staleness fix wirednotify_room_changed()intocreate_room/join_room/leave_room— every call that changes this server's own residency in a room — but missed the four calls that mutate a second user's membership on a room this server is already resident in. Reproduced from a real server's logs: a local user creates a room and invites a remote Synapse user; the remote server'smake_join404s from the worker on every retry, indefinitely, because the worker'sstore.roomssnapshot never learned the room existed (its own creation-time notification can be silently dropped if the worker's IPC channel wasn't yet healthy — a pre-existing, separate gap — and nothing else ever re-notifies for that room_id once only invites/kicks/bans happen against it afterward). All four now callnotify_room_changed(runtime, room_id)after committing the membership change, matching the existing pattern;tests/unit/test_homeserver_room_service.cppgains onetest_room_changed_log-pinned regression scenario per function, alongside the three already covering create/join/leave. Seedocs/architecture.md, "Federation worker room staleness".
0.10.14¶
Fixed¶
- fix(federation):
send_joinstill failed for a genuinely huge room with502 send_join failed: response_too_large, even after thejoin_timeout/base64-framing fixes in 0.10.13 gave it enough time and IPC headroom:OutboundClientrejects any response body larger thanhttp::OutboundRequest::max_response_body_bytes, a hardcoded 16 MiB used by every federation call — but asend_joinresponse embeds the room's entire current state (onem.room.memberper member plus the auth chain), and for a room likematrix.org's#communitywith tens of thousands of members that routinely exceeds 16 MiB long before any timeout or IPC frame limit is reached. Newsecurity.federation.join_response_max_size(default64MiB) givesmake_join/send_jointheir own response-size budget, threaded through a newOutboundCall::max_response_body_bytesfield (build_outbound_requestforwards it toOutboundRequest) and a newperform_sync_outbound_callparameter thatsend_join's call site populates from the config value; every other federation call keeps the 16 MiB default. Because the federation-worker IPC channel'soutbound_http_responseframe carries the base64-encoded body (see the 0.10.12 entry below), raising this cap also has to raise the channel'smax_frame_bytes— a newipc::frame_bytes_for_response_cap()computes the required frame size from the configured response cap, andWorkerPool(main process) andWorkerEventLoop(worker process) each derive it independently from the same config so both sides of the channel agree without negotiating it over IPC itself. This value is baked in at worker-spawn time, so unlike the otherjoin_*keys it isrestart_required, not hot-reloadable —reload_policy_for_keynow special-cases it. - fix(federation): inbound
make_join/state/etc. against a room this server just became resident in (viacreate_room, a federatedjoin_room, or a local join) could 404 from the federation worker even though the room fully exists in the database:merovingian-fed-workerruns its ownHomeserverRuntimewith its ownPersistentStore, hydrated once from disk at worker startup — every write helper (store_room,store_membership, ...) updates the database and that same process's in-memory vectors together, but nothing pushes a write made by the main process into a worker's already-running copy. Since inbound federation requests are routed to the worker, any room created or joined after the worker's last (re)start was invisible to it: a remote server'smake_joinagainst this server for that room hit the worker's stalestore.roomslookup and 404'd, even though the room was correctly persisted and the main process's own copy of the store had it — this is the root cause behind the "Failed to make_join via any server" report traced back throughmerovingian-server's logs showing a room and membership present insqlite3but a live 404 from the same server. Fix:create_room/join_room/leave_roomnow callHomeserverRuntime::federation_proxy->notify_room_changed(room_id)after committing a residency change;FederationProxy/WorkerPool::notify_room_changedresolve the owning shard and send a fire-and-forgetroom_syncIPC notification (ipc::serialize_room_sync_notification), and the worker's handler responds with a newdatabase::reload_room(), which re-reads just that room's rows (room, membership, invites, events, state, and the event-relation tables scoped to that room's events) from the database via parameterised, room_id-scoped queries — implemented for both SQLite and PostgreSQL — and replaces the worker's in-memory slice for that room, not a full re-hydration. Best-effort: a dropped notification is not surfaced to the caller. Seedocs/architecture.md, "Federation worker room staleness". - fix(database):
PersistentEvent::prev_event_ids/auth_event_ids/signaturessilently read back empty after any process restart, not just in the new federation-worker reload path above: hydrating aPersistentStorefrom disk only loaded the flatevent_edges/event_auth/event_signaturestables into their own top-level vectors; nothing joined those back onto the matchingPersistentEvent, which only carries populated DAG-linkage fields when an event is stored fresh within a process's own lifetime (store_event_with_state). This silently produced make_join/state responses with emptyprev_events/auth_eventsfor any room whose events were written before the current process started — no crash, no error, just a request-shaped-wrong response. Newdatabase::reconstruct_event_relations()re-derives these fields from the flat tables; called at the end of bothopen_sqlite_persistent_store/open_postgresql_persistent_storeand as part ofreload_room's snapshot merge.
Added¶
- test(federation): enforce the "every room-residency change notifies the federation worker" design contract directly, rather than only through the reload_room/reconstruct_event_relations/room_sync unit and integration tests above: proving a real, separate
merovingian-fed-workerprocess answers correctly for a room the main process just created turns out to be architecturally hard to test faithfully — the worker independently re-resolves the caller's identity even for a pre-verified request, which requires clearing a hardcoded SSRF check with no override reachable from outside the process (not a live test server, not a pre-seeded key cache, nothing short of weakening that check). NewHomeserverRuntime::test_room_changed_log(test-only, alwaysnullptrin production) lets tests assert the design contract directly and cheaply instead:create_room/join_room/leave_room's six call sites are consolidated through one function,notify_room_changed()inroom_service.cpp, which appends to this log when a test wires it, in addition to notifyingfederation_proxy. New scenarios intest_homeserver_room_service.cpppin all three operations (plus a negative case proving an unrelated room is never logged); a new assertion intest_join_room_flow.cpp's existing live federated-join test pins the real network round-trip path the same way. Fixes a genuine lifetime hazard found while writing the live-join assertion: the background member-fill task holds a reference to the runtime and can still callnotify_room_changed()after aTHENblock returns (the runtime's destructor blocks draining it, but the test doesn't wait explicitly) — the log vector must be declared before the runtime variable so it destructs after the runtime's blocking dtor, or the background task can write through a dangling pointer (reproduced as acorrupted size vs. prev_size in fastbinsheap-corruption abort before the fix). Seedocs/architecture.md, "Federation worker room staleness".
0.10.13¶
Fixed¶
- fix(federation):
send_joinIPC times out for large rooms because it usesremote_timeout_secondsinstead of the join budget:send_joinwas callingperform_sync_outbound_callwithruntime.federation.config.remote_timeout_seconds, the same short budget used for key and discovery fetches, instead ofjoin_timeout_seconds— despite thejoin_timeout_secondsfield being documented as "Separate budget for make_join/send_join/make_leave/send_leave". For a 30,000+ member room,matrix.orgcan take well over 60 s to assemble and transmit the full room state, so the IPC channel timed out and the main process returned502 send_join failed: IPC timeout waiting for outbound HTTP result.send_joinnow mirrors the same fallback chain asmake_join(lines 2877-2879):join_timeout_secondswhen configured, elseremote_timeout_seconds. - fix(federation):
perform_sync_outbound_callwithtimeout_seconds=0(unconfigured) produced a 10 s IPC window instead of using theFederationCalldefault (60 s):WorkerPool::send_outbound_requestcomputes the IPC channel timeout astotal_timeout_seconds + 10. When neitherjoin_timeout_secondsnorremote_timeout_secondsis set,timeout_seconds=0was written directly tocall.total_timeout_seconds, overwriting theFederationCalldefault of 60 s and collapsing the IPC window to just 10 s.perform_sync_outbound_callnow guards the assignment behindtimeout_seconds > 0; a zero value leaves theFederationCalldefaults untouched. - fix(config):
config/merovingian.conf.examplewas missing everysecurity.federation.join_*key andfederation.worker.apply_hardening: the example config documentedsecurity.federation.remote_timeoutbut never mentionedjoin_timeout,join_parallelism,join_race_deadline,join_max_candidates, orjoin_state_key_parallelism— the five keys added across 0.10.10/0.10.11 specifically to give the join/leave membership dance its own extendable budget, separate from the general 60 sremote_timeout. Operators following the example file had no way to discover these keys existed (they are documented indocs/configuration.md, but not surfaced in the file operators actually copy from). All five are now present with their code defaults (180s,8,45s,20,100).federation.worker.apply_hardening(seccomp/capability sandboxing for the federation worker, defaulttrue) was also missing and is now documented alongside the otherfederation.worker.*keys.
0.10.12¶
Fixed¶
- test(federation): lock
runtime.mutexbefore readingpersistent_storeintest_join_room_flow.cpp's immediate-success assertions, fixing a real ThreadSanitizer-detected data race: the CItsanjob aborted (SIGABRT,killed by signal 6) with a genuine race reported atsrc/database/persistent_store.cpp:978instore_membership— not a timeout.join_roomreturns as soon as critical state is persisted and hands the bulk membership fill to a background task tracked inruntime.orphan_futures_, which writes topersistent_store.state/.membershipsunderruntime.mutex(see the 0.10.11 fast-join entry below). The test's firstTHENblock read those same containers on the main thread immediately afterjoin_roomreturned, with no lock at all, while that background task was still running and concurrently callingstore_membership'semplace_back— a real unsynchronized read/write race on the vector, exactly what TSan is designed to catch. Every production call site accessespersistent_storeonly while holdingruntime.mutex; the test now does the same. The secondTHENblock was already race-free: it calls.wait()on every future inorphan_futures_before reading, which establishes a happens-before edge with the background task's writes. - fix(ipc): base64-encode response bodies relayed over the federation-worker IPC boundary so large-room
send_joinresponses stop busting the frame cap (#342): joining a large room reliably failed with a client-visible502 send_join failed: timeout, even after a candidate resident server answeredsend_joinsuccessfully.fed_responseandoutbound_http_responseframes (src/ipc/federation_ipc_frames.cpp) embedded the HTTP response body via raw JSON-string escaping, whose expansion depends on content: every"/\doubles and every raw control byte becomes a 6-byte\u00XXescape. Asend_joinresponse for a large room routinely reacheshttp::OutboundRequest::max_response_body_bytes(16 MiB by default) — quote-dense room-state JSON at that size escapes to more than the (then-equal) 16 MiBmerovingian::ipc::kIpcMaxFrameBytescap, soIpcChannel::send_responsesilently dropped the oversize frame (ipc: dropping oversize response frame) andWorkerPool::send_outbound_requeston the main side just timed out waiting, surfacing asroom.join.remote.send_join_failed reason="IPC timeout waiting for outbound HTTP result". The body is now base64-encoded before framing (base64_encode_body/base64_decode_body), a fixed, content-independent 4/3 expansion, andkIpcMaxFrameBytesis raised to 24 MiB to hold a max-size response with headroom to spare. Also corrects a staledocs/hardening.md/header comment claiming the frame cap is configurable viafederation_worker.max_ipc_frame_bytes; no such config field exists — the cap is a compile-time constant.
Added¶
- test(federation): live
join_roomintegration test against a real local TLS server, closing the codecov/patch gap from #341:join_room's post-send_join-success path (fast-join state splitting, signature verification, background membership fill) had no integration coverage becauseperform_sync_outbound_callalways resolves destinations throughfederation::discover_server(), which unconditionally rejects loopback/private-range addresses (src/federation/security.cppip_address_is_private_or_loopback, no config or test-mode override) — so a local test server can never be reached through the real discovery path, and production outbound calls never populateOutboundRequest.trusted_ca_pem, so a self-signed test certificate would fail TLS verification even if discovery succeeded.HomeserverRuntimegains a test-only outbound override (a map from destinationserver_nameto a forced resolution + trusted CA PEM), consulted first inperform_sync_outbound_call; unset in every production construction path, so real-server SSRF and TLS-trust behaviour is unchanged. Newtests/integration/test_join_room_flow.cppreuses the TLS test-certificate/TcpAcceptorpattern fromtest_federation_outbound_flow.cppto stand up a real resident server answeringmake_join/send_join, drivingjoin_roomthrough an actual signed federation round trip.
0.10.11¶
Added¶
- feat(federation): fast join — verify and persist critical room state synchronously, defer the bulk membership list to a background task: even with bounded-parallel key resolution, a
join_roomcall that waits on verifying every member'sm.room.memberevent before returning still pays a real cost proportional to the number of distinct member home servers in the room — for a 30,000-member room, potentially hundreds of key resolutions before the client sees success. Newsplit_send_join_state_eventsseparates a send_join response'sstatearray into "critical" state (create, power_levels, join_rules, history_visibility, encryption, our own membership — everything an auth check on the joining user's own next action could depend on) and "background" state (every other member'sm.room.member). Critical state andauth_chainare verified (network-bound key resolution now runs beforeguard.lock(), so it never holdsruntime.mutex) and persisted beforejoin_roomreturns; background state is verified and persisted by an async task tracked in the existingorphan_futures_queue (same drain-on-shutdown guarantee as a losing make_join race candidate), loggingroom.join.background_state_completeon completion. The verify-before-persist invariant is unchanged for every event — nothing is exposed via/syncbefore its signature is checked, regardless of timing. This is a deliberately bounded-scope version of the "partial state room" trade-off Synapse's faster-joins feature makes:room_has_member()/the/membersendpoint may not list every member until the background fill completes, but no auth-critical state is ever deferred. Seedocs/threat-model.md.
Fixed¶
- fix(security): verify send_join state/auth_chain event signatures instead of trusting the resident server wholesale, with bounded-parallel key resolution:
join_room's ingestion of asend_joinresponse'sstateandauth_chainarrays persisted every event straight to the event graph with no Ed25519 signature check and no remote signing-key fetch — a direct violation ofsrc/federation/AGENTS.mdrule 2 ("Verify every inbound PDU's signature... Unverified events must be silently dropped"), and the actual bottleneck behind "there are 30,000+ users that need checking" for a large room: doing this correctly means resolving a signing key for every distinct member home server represented in the room, not just racing candidate servers faster. Newfilter_verified_send_join_eventsresolves distinct(sender_domain, key_id)pairs across both arrays via the existingremote_key_resolver/key-cache with concurrency capped by a newsecurity.federation.join_state_key_parallelism(default100, reloadable) — deliberately not unbounded, since a 30,000-member room can span hundreds of distinct home servers and firing all of them at once would just trade one unbounded-fan-out bug for another. Events whose sender is our own server are trusted without a resolver round trip. Fail-closed: an event whose key cannot be resolved, or whose signature does not verify, is silently dropped rather than persisted — and does not fail the join, so one uncooperative or unreachable member home server degrades that member's row rather than blocking everyone else's. - fix(federation): advertise all server-supported room versions (v1-v12) in outbound make_join, not a hardcoded v10-v12: joining any room not on room version 10, 11, or 12 failed against every real resident server — including matrix.org — with
400(M_INCOMPATIBLE_ROOM_VERSION), becausejoin_room's outboundGET /make_joinrequest only ever sentver=10&ver=11&ver=12. This was a pure omission:rooms::room_version_policy.cppalready fully implements and conformance-tests (test_room_version_table_conformance.cpp, "a server MUST be able to participate in rooms of all stable versions") auth rules, redaction rules, event format, and event-ID computation for v1 through v12, and the rest of the join pipeline (validate_make_join_event,sign_event_for_server,make_reference_hash_event_id) already dispatches generically on whateverroom_versionthe response reports.supported_versionsis now derived fromrooms::known_room_versions()(the same source of truth used elsewhere) instead of a literal. A large, long-lived room (the kind likely to have tens of thousands of members) is essentially never on the three newest versions, so this was silently failing every federation join to such rooms.GET /_matrix/client/v3/capabilities'sm.room_versions.availablehad the identical hardcoded-subset bug (misleading clients about which versions are valid to create/upgrade rooms to) and is fixed the same way. - fix(federation): bound the make_join race with an overall deadline and cap candidate count: joining a large, well-federated room (e.g. via a
vialist spanning dozens of resident servers) could grind for 8+ minutes — the 0.10.10 parallel race capped concurrency (join_parallelism, default 8) but not total elapsed time: with N candidates and a per-call budget up tojoin_timeout(180s), the race took roughlyceil(N/join_parallelism) * up_to_180swith no upper bound, while the client's ownfetch(and any reverse proxy in front) gave up long before the server did, surfacing as "Failed to fetch" even though the server kept working. Two new independent config keys fix this:security.federation.join_race_deadline(default45s, reloadable) bounds the entire race — not just each candidate — after whichjoin_roomreturns502with candidates still in flight parked inorphan_futures_(unchanged draining behaviour);security.federation.join_max_candidates(default20, reloadable) truncates the ordered candidate list before anymake_joinprobe is spawned, since every candidate — regardless ofjoin_parallelism— got an OS thread immediately viastd::launch::async, so an unboundedvialist meant unbounded upfront thread spawning. Both default to values that preserve the 0.10.10 behaviour for small via lists while guaranteeing a bounded, real HTTP response for large ones. - test: cover the race deadline, candidate cap, room-version advertisement, and send_join signature verification: new scenarios in
test_config_federation_join.cpp(defaults, parsing, validation, hot-reload classification for the three new join-related keys) andtest_join_routing.cpp(cap_join_candidates— order-preserving truncation, no-op when under cap,0clamped to1);test_homeserver_room_service.cppgains scenarios exercisingjoin_roomwith both a configured deadline and a candidate list exceedingjoin_max_candidates, confirming truncation and the distinct "race deadline exceeded" vs. "all candidates exhausted" rejection reasons.test_client_server.cpp's capabilities scenario now asserts all twelve versions (v1-v12) are advertised as stable, not just v10-v12.test_federation_invite_join.cppgains fivefilter_verified_send_join_eventsscenarios built on real signed test events (tests/federation_signing_test_support.hpp): a validly signed event survives, an event with an invalid signature is dropped, an event whose sender-domain key cannot be resolved is dropped, a self-signed (our-server) event is kept without invoking the resolver at all, and — mirroring the concurrency-proof pattern already used for the inbound parallel PDU sender-key resolver — six distinct sender domains resolve exactly once each with measured peak concurrency never exceeding a configuredjoin_state_key_parallelism=2. Six more scenarios coversplit_send_join_state_events: room-level state events (create, power_levels, join_rules, history_visibility, encryption) stay critical; the joining user's own membership stays critical; other members' membership is deferred; a realistic mixed array splits correctly; a malformed/unclassifiable entry defaults to the synchronous critical path rather than being silently dropped; and an empty input returns an empty split.
0.10.10¶
Fixed¶
- fix(federation): configurable join timeout for make_join/send_join/make_leave/send_leave: the make_join call used the shared 60s
security.federation.remote_timeout, so joining a large remote room (e.g. on matrix.org) timed out with502 make_join failed: timeoutwhen the resident server's make_join was slow. A newsecurity.federation.join_timeout(default180s, reloadable) gives join/leave membership calls a separate, extendable budget, independent of the 60s general federation timeout. - fix(federation): race make_join across candidate servers in parallel: the candidate loop tried each
via/server_nameresident server sequentially with the full timeout each.make_joinnow runs against all candidates concurrently (capped bysecurity.federation.join_parallelism, default8, reloadable) and the first success wins;send_jointargets that server (spec §Joining Rooms). If one via server is fast and another is slow, the join completes at the speed of the fastest, and a genuinely-slow-but-eventual resident server gets the fulljoin_timeoutbudget. - fix(federation): TTL discovery cache for server-name resolution: the remote key resolver re-ran the full
.well-known+ SRV + DNS discovery cascade on every call, even on cache hits. A newCachedServerDiscovery(60s TTL, negative cache, thread-safe, mutex-guarded) wraps the discovery network so cache-hit lookups skip the cascade, removing repeated DNS latency from key resolution and outbound calls. SSRFpinned_addressesare never returned stale past TTL. -
fix(federation): parallel inbound PDU sender-key resolution: the inbound
/sendhandler resolved each PDU sender's signing key serially. Distinct(sender_domain, key_id)pairs across the transaction are now resolved concurrently (capped byjoin_parallelism) before per-PDU verification, while preserving fail-closed per-PDU rejection and the HTTP 200 + per-PDU error-map response (returning 4xx/5xx makes Synapse back off the whole destination). -
test: extend join_room and inbound parallel resolver coverage: added eight new Catch2 BDD scenarios covering previously-untested branches:
join_roomrejects unauthenticated callers (401), returns 404 when no candidate servers exist (room on own domain, no via), returns 200 idempotently when the user is already a local member, and the parallel race withjoin_parallelism=0(clamped to 1),join_timeout_seconds>0(uses join budget over remote timeout), and five candidates throttled by a parallelism=2 semaphore. Two new inbound scenarios coverjoin_parallelism=1(serial path, peak concurrency ≤ 1 verified) andjoin_parallelism=0(clamped to 1, all PDUs accepted). - test: cover the
CachedServerDiscovery-backedremote_key_cacheoverloads and theHomeserverRuntimeorphan-future destructor: thefetch_remote_server_keys(client, CachedServerDiscovery&, ...)andmake_persistent_remote_key_resolver(store, client, CachedServerDiscovery&, ...)overloads — the pair actually wired bystart_runtime/local_http_router.cppwhenevercached_discoveryis available — had zero direct test coverage; only the raw-ServerDiscoveryNetworkoverloads and the standalone cache helpers were exercised. Three new scenarios intest_remote_key_cache.cppprove: a failed discovery fails the fetch closed without an outbound call and is served from the negative cache on retry; a fresh cached key is served without repeating the resolver's own discovery lookup across two resolver calls (network.lookup_calls == 1); and a resolver with no cached key and failing discovery returnsnullopton both calls with discovery deduped. A fourth new scenario intest_homeserver_room_service.cppdirectly exercisesHomeserverRuntime::~HomeserverRuntime()'s orphan-future drain: a slow future is parked inorphan_futures_and the runtime is destroyed while it is still running, asserting the destructor blocks until it completes rather than returning immediately.
Deferred: multi-server signing-key batching via
POST /_matrix/federation/v1/query/keys— that endpoint is for E2EE device keys, not server signing keys; the v1.18 spec defines no signing-key batch endpoint (signing keys are single-serverGET /_matrix/key/v2/server). Parallel fan-out achieves the same wall-time without a spec deviation or a new untested inbound handler.
0.10.9¶
Fixed¶
- fix(ipc): authenticate the federation-worker IPC key exchange (#318): the
IpcChannelcrypto_kxhandshake was unauthenticated, so any process able to reach the inherited AF_UNIX fd could complete the handshake and inject AEAD frames. Both the main process and the worker now derive the same 32-byte IPC auth key from the operator master-key file (the same material used for v4 access-token keys) and MAC each other's ephemeral KX public keys with a domain-separated key (merovingian:ipc-channel-auth:1) before deriving session keys. Peers that cannot prove possession of the master key are rejected (fail-closed).load_master_key_materialis lifted into a shared header so both processes link the same loader. - fix(ipc): replace the hand-rolled IPC JSON parser with the fuzzed canonicaljson parser (#320): IPC frame bodies were parsed with
json.find("\"key\":\"")substring searches that broke on escaped quotes, keys appearing inside string values, or}inside header values, andstd::from_charsresults were not checked. Alldeserialize_*functions insrc/ipc/federation_ipc_frames.cpp, theid/reply_toextraction insrc/ipc/channel.cpp, and the duplicated parsers insrc/federation_worker/worker_event_loop.cppnow use the existing depth-bounded, fuzzedcanonicaljson::parse_json(). Malformed frames are rejected instead of misparsed. - fix(ipc): stop
noexceptIPC frame functions terminating on allocation failure (#324):IpcChannel::write_frame/read_framewere markednoexceptbut allocatedstd::vector/std::stringup to the frame cap;std::bad_allocwould callstd::terminate. The allocations are now wrapped intry/catch(std::bad_alloc)returningfalse/std::nullopt, preserving thenoexceptcontract and letting callers fail gracefully. - fix(ipc): lower the IPC frame cap and stop silently dropping oversize responses (#325): the 50 MiB per-frame cap enabled memory-exhaustion DoS across concurrent workers. The default is lowered to 16 MiB and made configurable via
federation_worker.max_ipc_frame_bytes. Oversize frames are now logged and propagated as send/request failures instead of being silently dropped. - fix(homeserver): don't inherit the full parent environment in the federation worker (#330):
posix_spawnwas called withenvp = nullptr, leaking every parent environment variable (including any secrets) to the lower-privilege worker. The supervisor now passes an explicit minimal environment (PATHonly) to the worker child. - fix(platform): apply a worker-specific seccomp filter and runtime hardening to
merovingian-fed-worker(#319): the worker binary inherited the main process's more-permissive seccomp filter (which deliberately allowsexecve/execveatso the worker could install a stricter one) but never installed its own. The worker now appliesPR_SET_NO_NEW_PRIVS, drops capabilities, sets resource limits, and installs a stricter seccomp-bpf filter that deniesexecve/execveat/spawn-orientedclone(the worker never execs or spawns).docs/hardening.mdand the seccomp self-check are updated. - fix(platform): disable exit-time LeakSanitizer in the federation worker under ASan (#319): the worker's seccomp filter denies
ptraceby design (an escalation primitive a compromised worker must never have). ASan's exit-time LeakSanitizer usesptrace(viaStopTheWorld) to suspend threads before scanning for leaks; withptracedenied, the tracer thread is killed andStopTheWorldspun insched_yieldforever, so the worker process never exited and the supervisor'swaitpid()hung — producing the asan-ubsan CI integration-test timeout. The worker now defines__asan_default_options()returningdetect_leaks=0(C linkage, global scope, guarded to ASan builds only) so the worker skips the exit leak check; the main process retains full ASan/LSan coverage and the worker's correctness is covered by its own unit/integration tests. Production (non-sanitised) builds are unaffected. - fix(auth): derive the legacy v3 access-token HMAC key from the master key instead of the Ed25519 seed (#322):
token_hmac_key_v3copied the first 32 bytes of the Ed25519 signing seed and used it directly as the access-token HMAC key, violating cryptographic key separation. The v3 key is now master-key-derived with a distinct domain separator (merovingian:access-token-hmac:legacy-v3:1). Breaking: existingtoken-hash:v3:hashes no longer validate; affected sessions must re-login and are issued v4 tokens (the current issuance path). The signing seed is no longer used as a MAC key. Without a master key configured, v3/v4 are both unavailable and token issuance falls back to the unkeyed v2 hash; configure a master key for hardened token hashing. - fix(database): mark the server signing secret as sensitive for prepared-statement redaction:
store_server_signing_keybound thesecret_keycolumn as apublic_value, sosensitive_values_are_redactedflagged the store once a master key was configured (the at-rest encrypted formsecretbox:v1:…contains the substring "secret"). The signing secret is now bound assensitive_value, consistent with the other key/signature parameters — the encrypted-or-plaintext signing secret is always redacted from query-parameter logging. - fix(federation): handle escaped quotes in the X-Matrix Authorization header parser (#321):
parse_x_matrix_authorization_headerended a quoted value withremaining.find('"'), ignoring\"escapes per RFC 7230, so legitimate headers containing escaped quotes failed to parse and attacker-controlled headers could terminate values early. The parser now skips\"/\\escape sequences when scanning for the closing quote. - fix(crypto): keep the server signing secret in
core::SecretBuffer, notstd::string(#317): multiple call sites copied the Ed25519 signing secret out of the mlocked/zeroisedSecretBufferinto a heapstd::stringviareinterpret_cast, which is not pinned or zeroised and may leak into freed arenas or core dumps.make_federation_signature,OutboundCall::secret_key,DispatchWorkerConfig::secret_key, andperform_sync_outbound_callnow acceptstd::span<std::uint8_t const>/ own acore::SecretBuffer; call sites passsigning_secret_key.bytes()directly. - fix(ipc): stop shipping raw peer X-Matrix credentials to the federation worker (#323): inbound
fed_requestframes serialized the raw peerAuthorizationheader (origin/key/sig) into the IPC body, letting a compromised worker harvest and replay peer homeserver credentials. The main process now verifies the inbound X-Matrix signature itself (verify_inbound_federation_signature) and forwards only the verified identity (origin,key_id,sig_verified) to the worker over the now-authenticated channel; the rawaccess_token/signature never crosses IPC, andAuthorization/X-Matrixheaders are stripped from thefed_requestframe. The worker'shandle_federation_http_requestbuilds aSignedFederationRequestfrom the verified identity (signature_verified = true) and skips re-verification. Outbound residual (not addressed by this change): the outboundAuthorizationheader carried onoutbound_http_requestframes is our own request-bound X-Matrix signature (bound to the method/url/body/destination of the exact request the worker sends), not a reusable peer credential, so the harvest/replay risk that motivated this issue does not apply outbound; the signing secret never enters the worker (#317). Relocating outbound signing into the worker viaIpcEd25519Provider— so the signed value never crosses IPC — is deferred; it requires a provider-abstraction refactor ofbuild_outbound_requestfor minimal additional security value.
0.10.8¶
Fixed¶
- fix(platform): seccomp filter SIGSYS crash on glibc 2.35+ when built with older kernel headers:
rseq(Linux 4.18),membarrier(Linux 4.3),getcpu, andfutex_waitv(Linux 5.16) were conditionally included in the BPF allowlist only when the build-time kernel headers defined the corresponding__NR_*macros. On hosts built against Ubuntu 18.04 headers (Linux 4.15) or Ubuntu 22.04 headers (Linux 5.15), these macros are absent and the filter silently drops the entries. When the binary then runs on a host with glibc 2.35+, thread initialisation unconditionally callssys_rseq(), which is blocked bySECCOMP_RET_KILL_PROCESS— delivering SIGSYS and killing the server immediately afterevent=start.complete. Fixed by adding architecture-specific numeric fallbacks matching the pattern established in v0.10.6 forclone3(435),close_range(436), andfaccessat2(439): x86_64 fallbacks are 334/324/309/449; aarch64 fallbacks are 293/283/168/449. - fix(platform): seccomp filter SIGSYS crash in ThreadSanitizer federation worker: the production seccomp allowlist omitted
personality(135). ThreadSanitizer callspersonality(ADDR_NO_RANDOMIZE)in the federation worker afterexecveto disable ASLR for deterministic shadow-memory layout. Because the worker inherits the server's seccomp-bpf filter, the call was killed with SIGSYS before the IPC handshake, producing"Federation worker failed to start: ipc: public key exchange failed"in the TSan integration test.personalityis now explicitly allowed in the filter. - fix(federation): orphaned federation workers persist after parent crash/kill in tests: the federation worker now calls
prctl(PR_SET_PDEATHSIG, SIGTERM)immediately after startup so the kernel terminates the child automatically when the spawning parent thread exits. This prevents leftover worker processes from spinning at 100% CPU when integration tests kill the server process or when the server crashes. - fix(tests): binary startup hardening test failed in CI containers that run as root:
tests/integration/test_server_startup_hardening_flow.cppnow skips (WARN) when the test process runs as root, because the server refuses to start as root by design (the privilege-drop and filesystem-restriction self-checks reportdisabled) and the test can never reach the listening state. This fixes the blockingdebian/fedora/rhel/opensuseCI jobs, which run in containers as root. A pre-REQUIRE(ready)refusal check also skips when the server logsStartup refused: hardening self-checkbefore binding listeners. - fix(tests): binary startup hardening test crashed under ThreadSanitizer: the TSan-instrumented server is killed by the seccomp filter on startup (TSan's runtime issues syscalls the allowlist does not permit beyond
personality), which surfaced as asignal-unsafe call inside of a signalin the Catch2 fatal-error handler. The test now skips under__SANITIZE_THREAD__/__has_feature(thread_sanitizer); the asan-ubsan CI job continues to exercise the same startup path. - fix(tests): hardening self-check unit test failed when the test process ran as root:
tests/unit/test_hardening_self_check.cpppreviously asserted indices 9 (privilege drop) and 10 (filesystem restrictions) wereenabledorunknown. On Linux these are now definitive —enabledfor a non-root UID,disabledfor root — so the assertion failed in root CI containers. Indices 9/10 are now split out and asserted per-platform:enabled/disabledon Linux,unknownelsewhere. - fix(tests): NetBSD compile error in
wait_for_log_line:tv.tv_usecwas assigned viastatic_cast<long>and then implicitly narrowed tosuseconds_t(akaint) on NetBSD, triggering-Wshorten-64-to-32(treated as error). Cast directly tosuseconds_t; the value (0..999000) always fits. - fix(tests): hardening self-check unit test failed on NetBSD: the platform-specific sandbox scenario in
tests/unit/test_hardening_self_check.cpphad an#elsebranch (covering NetBSD and any platform that is not Linux/OpenBSD/FreeBSD) that assertedpledge/unveilandcapsicumreportunknown. The implementation correctly reportsenabledfor controls that are not applicable to the host OS (no security gap — the same treatment Linux receives for these BSD-only primitives);unknownwould wrongly block startup viais_ready()on platforms that can never apply them. The#elsebranch now assertsenabledfor both, matching the implementation and the v0.10.8 "no alpha exceptions" design. This was the last failing NetBSD CI job (1 of 886 unit cases, exit status 42). - ci(netbsd): surface the Catch2 failure report from
testlog.txton unit-test failure: the NetBSD job'smeson test --print-errorlogstruncated output to the last 100 lines, which were all structured runtime INFO logs — Catch2's own failure report (test name +file:line) was buried, and the core-backtrace group was empty because assertion failures do not produce cores. The failure branch now filters runtime log lines and[netbsd-diag]markers out ofbuild/meson-logs/testlog.txtand prints the Catch2 section headers,FAILEDassertions, and test-case summary to the live log.
Changed¶
- refactor(platform): remove
HardeningStatus::alpha_exceptionand require every hardening check to reportenabled: the alpha-phase carve-out status is deleted from the hardening self-check.is_alpha_ready()is replaced byis_ready(), which returns true only when every hardening check isenabled;unknownanddisabledboth block startup.src/main.cppnow runs the final self-check after all platform controls are applied: afterstart_client_server()has applied the OpenBSDpledge/unveilprofile and after FreeBSDcap_enter()has entered Capsicum capability mode.docs/hardening.mdis updated to remove all alpha-exception language. - test(platform): binary startup integration test skips cleanly when the environment cannot satisfy full hardening:
tests/integration/test_server_startup_hardening_flow.cppnow detects a hardening-gate refusal from the server log and returns withWARN(...)instead of failing, so Meson (which treats Catch2SKIP()exit code 4 as failure) reports a clean skip.wait_for_log_linepreserves all drained output so the refusal line is not lost when it arrives in the sameread()as "Listeners active". The refusal is checked in every poll iteration and again after the child exits, so a server that refuses startup and immediately terminates is still reported as a skip.ServerGuard::alive()useswaitpid(WNOHANG)so a server that exits between "Listeners active" and the idle check is not misreported as alive due to zombie PID reuse. The idle-window failure message includes both the startup log and the post-listeners drain for diagnosis.
Fixed¶
- fix(tests): restore hardening self-check snapshot for
admin_health:src/homeserver/runtime.cpppopulatesruntime.hardeningwithrun_startup_hardening_self_check()before marking the runtime started, so the health endpoint reportshardening:okin unit tests. The final startup gate insrc/main.cppstill overwrites the snapshot after all platform controls are applied. - fix(tests): smoke test expects the new readiness summary format:
tests/smoke/meson.buildno longer greps for the removedalpha_readyfield; it checks forHardening readiness: ready=false blockers=Nin--dry-runoutput. - chore(tests): remove temporary diagnostic script:
scripts/tmp_startup_test.shis deleted.
Added tests¶
- test(platform): rseq/membarrier/getcpu/futex_waitv numeric values are always present: new WHEN block in
test_seccomp_hardening.cppunconditionally checks the numeric syscall values (334/324/309/449 on x86_64; 293/283/168/449 on aarch64) are in the allowlist, regardless of what__NR_*macros the build headers define — mirroring the existing clone3/close_range/faccessat2 numeric assertions added in v0.10.6. - test(platform): hardening self-check no longer tolerates alpha exceptions: updated
test_hardening_self_check.cppto assert the absence ofalpha_exception, to verifyis_ready()fails closed when runtime controls are not applied, and to check that platform-specific sandbox controls map toenabled(not applicable) orunknown(not yet applied) instead ofalpha_exception. - test(platform): seccomp allowlist allows ThreadSanitizer
personalitysyscall: new SCENARIO intests/unit/test_seccomp_hardening.cppasserts thatpersonality(135) is in the allowlist so the TSan federation worker is not killed with SIGSYS during startup.
0.10.7¶
Added¶
- feat(platform): implement OpenBSD
pledge(2)+unveil(2)sandbox:apply_runtime_hardening_controls()now callsunveil()for every path in the BSD hardening profile (read-only paths get"rx", writable paths get"rwc",/etc/sslgets"r"for the LibreSSL CA bundle), locks the vnode allowlist withunveil(NULL, NULL), then appliespledge("stdio rpath wpath cpath flock inet unix dns proc exec", NULL). The promise set includesproc execfor the thumbnail and federation worker child processes andunixfor the AF_UNIX IPC channel.openbsd_pledge_is_active()andbsd_pledge_promises()are now exported fromruntime_hardening.hppfor testing. Tested on the Tier 1 OpenBSD CI job (vmactions/openbsd-vm@v1). - feat(platform): implement FreeBSD Capsicum
cap_enter(2)capability mode:apply_freebsd_capsicum_capability_mode()is a new public API that enters Capsicum capability mode. It is called fromrun_server()inmain.cppafter all resources are open (listeners bound, TLS loaded, federation worker spawned viaposix_spawn()by path). Before callingcap_enter, the thumbnail worker binary is pre-opened withO_RDONLY | O_EXEC | O_CLOEXECand the fd is stored inRuntimeMediaConfig::thumbnail_worker_fd. The child process inthumbnailer.cppnow callsfexecve(fd, argv, environ)on FreeBSD when the pre-opened fd is available, allowing thumbnail generation to continue after the global filesystem namespace is forbidden.freebsd_capsicum_is_active()probescap_getmode(2)and is exported for testing. Tested on the Tier 1 FreeBSD CI job (vmactions/freebsd-vm@v1).
Fixed¶
- fix(platform): FreeBSD
thumbnailer.cppbuild failure: move theenvirondeclaration to namespace scope withextern "C"linkage in the FreeBSD-onlyfexecve()path. FreeBSD's<unistd.h>exposesenvirononly with C linkage, so the previous::environlookup failed withno member named 'environ' in the global namespace, and an earlier attempt to declare it at block scope was ill-formed. The global declaration preserves the existing behavior of passing the current environment to the thumbnail worker. - fix(platform): OpenBSD temp-directory failures on CI runners: introduce
merovingian::tests::temporary_directory()with a fallback chain (std::filesystem::temp_directory_path()→/var/tmp→/tmp→/var/run→ current path) and use it in all test scratch-file helpers and direct test callers. This makes the test suite robust on restricted CI images where/tmpis reported as not-a-directory.default_bsd_hardening_profile()keeps/tmpinfilesystem.writable_pathsso real OpenBSD deployments with a normal/tmpcontinue to have it unveiled. - test(platform): update BSD writable-path assertions after adding
/tmp:tests/unit/test_runtime_hardening.cppnow expects three BSD writable paths (/var/lib/merovingian,/var/run/merovingian,/tmp) instead of two, matching the updated default profile. - chore(tests): gate NetBSD diagnostic prints to NetBSD only: the
[netbsd-diag]std::cerrlogging intests/unit/test_federation_invite_join.cppandtests/unit/test_otk_signature_validation.cppnow only emits on__NetBSD__; on Linux and other CI platforms the output is compiled away so it no longer looks like NetBSD-specific tests are running there. - fix(platform): FreeBSD Capsicum probe compilation error in
runtime_hardening.cpp:auto mode = unsigned int{0U};is rejected by the FreeBSD toolchain as an ill-formed braced functional cast; replaced withunsigned int mode = 0U;so the FreeBSD CI build and package jobs compile. - fix(ci):
MESON_TEST_TIMEOUT_MULTIPLIERwas ignored by the sanitizers workflow:scripts/build-linux.sh,scripts/build-bsd.sh, andscripts/build-wsl.shnow translate the environment variable intomeson test --timeout-multiplier, so the ASan/UBSan and TSan integration-test runs get the intended 3× timeout instead of being killed at the 600 s default. - fix(ci): OpenBSD/BSD CI test runs permanently restricted themselves with pledge/unveil:
start_runtime()now skips applying runtime hardening controls whenMEROVINGIAN_TEST_DISABLE_HARDENINGis set. The build scripts export this variable duringmeson test, and the PostgreSQL integration workflow sets it for its direct binary invocation, keeping the Catch2 test process unrestricted while production server binaries continue to apply hardening. - fix(platform): FreeBSD package build compile errors in
main.cpp: add the missing#include "merovingian/platform/runtime_hardening.hpp"soapply_freebsd_capsicum_capability_mode()is visible, and replace the non-existentLOG_WARNmacro withLOG_WARNINGin the FreeBSD capability-mode entry block.
Changed¶
- chore(platform): update BSD hardening profile rejection message: the fallback for non-OpenBSD/non-FreeBSD BSDs (e.g. NetBSD) now reads
"BSD sandbox helpers are not yet implemented on this BSD variant"to distinguish from the old message that previously also covered OpenBSD and FreeBSD.
Added tests¶
- test(platform): OpenBSD pledge probe returns false before pledge is applied: new
#ifdef __OpenBSD__SCENARIO intest_runtime_hardening.cppassertsopenbsd_pledge_is_active()returns false in the test runner (pledge not applied) — exercises the probe without permanently restricting the test process. - test(platform): OpenBSD pledge promise set covers all homeserver categories: new
#ifdef __OpenBSD__SCENARIO asserts all ten promise categories (stdio,rpath,wpath,cpath,flock,inet,unix,dns,proc,exec) are present inbsd_pledge_promises()and the string is space-separated. - test(platform): FreeBSD Capsicum probe returns false before capability mode entry: new
#ifdef __FreeBSD__SCENARIO assertsfreebsd_capsicum_is_active()returns false (cap_enter not called). - test(platform): FreeBSD
apply_runtime_hardening_controlsaccepts BSD profile without cap_enter: new#ifdef __FreeBSD__SCENARIO callsapply_runtime_hardening_controlson FreeBSD (safe — returns accepted without calling cap_enter) and asserts accepted + probe still false. - test(platform): BSD runtime hardening scenario is now platform-conditional: the old scenario "BSD runtime hardening controls remain documented alpha exceptions" is replaced with a platform-aware equivalent: profile evaluation always accepts;
apply_runtime_hardening_controlsis tested on FreeBSD (accepts, Capsicum deferred) and non-OpenBSD/non-FreeBSD (rejects with unimplemented message); skipped on OpenBSD to avoid permanently pledging the test process. - test(platform): self-check BSD scenario comment updated: the "BSD sandbox controls are alpha_exception" scenario in
test_hardening_self_check.cppis updated to explain the correct reason (startup self-check runs beforeapply_runtime_hardening_controls, so probes return false at check time).
0.10.6¶
Fixed¶
- fix(platform): SIGSYS crash on Fedora / modern Linux at startup: the seccomp-BPF allowlist conditionally included
clone3(435),close_range(436), andfaccessat2(439) only when the build-time kernel headers defined the corresponding__NR_*macros. Binaries compiled against Ubuntu 20.04linux-libc-dev(kernel headers 5.4) therefore omitted all three, becauseclose_rangeandfaccessat2were added in 5.9 and 5.8 respectively. On Fedora 36+ and other hosts with glibc 2.34+, the firstpthread_createorposix_spawncall after seccomp was applied usedclone3; without it in the filter the process was killed immediately withSECCOMP_RET_KILL_PROCESS(SIGSYS). All three syscalls now use a hardcoded numeric fallback for x86_64 and aarch64 when the macro is absent, ensuring they are always present in the deployed filter regardless of the build environment's kernel header version.
Added tests¶
- test(platform): seccomp allowlist always contains clone3/close_range/faccessat2 on x86_64 and aarch64: new WHEN block in
tests/unit/test_seccomp_hardening.cppasserts that syscalls 435, 436, and 439 are present in the compiled filter by raw number, catching any future regression where a#ifdef __NR_*guard silently drops them from the allowlist. - test(platform): non-Linux seccomp probe returns not-probed and maps to
unknown:#ifndef __linux__SCENARIO intests/unit/test_seccomp_hardening.cppverifies thatprobe_seccomp_status()returns{probed: false, seccomp_active: false}on BSD (no/proc/self/status) and thatseccomp_check_from_probemaps it toHardeningStatus::unknown— notalpha_exceptionordisabled. - test(platform): self-check pins BSD sandbox controls as always
alpha_exception: new SCENARIO intests/unit/test_hardening_self_check.cppassertspledge/unveil(index 7) andcapsicum(index 8) are alwaysalpha_exception, neverenabled, sinceenabled_or_alpha_exception(false, ...)is hardcoded for both. - test(platform): self-check maps Linux-only controls to correct non-Linux statuses:
#ifndef __linux__SCENARIO intests/unit/test_hardening_self_check.cppasserts thatseccomp(index 6) isunknownon non-Linux (notalpha_exception) and thatcore dump policy,no_new_privs, andcapability bounding(indices 11–13) arealpha_exceptionon non-Linux, where the Linux kernel features are unavailable. - test(platform): BSD hardening plan is exhaustively tested — all six documentation fields: new SCENARIO in
tests/unit/test_runtime_hardening.cppindividually sets each ofunveil_documented,capsicum_documented,jail_documented,chroot_documented, andsetrlimit_documentedtofalseand asserts each independently causesevaluate_runtime_hardening_profileto reject with"bsd hardening plan is incomplete". - test(platform): BSD and Linux writable path sets use platform-appropriate mount points: new SCENARIO in
tests/unit/test_runtime_hardening.cppdocuments that both profiles share/var/lib/merovingianwhile Linux uses/run/merovingianand BSD uses/var/run/merovingian, and asserts the platform-specific socket directories do not cross-appear in the other profile. - test(platform): BSD profile rejects unsafe filesystem paths with the same rules as Linux: new SCENARIO in
tests/unit/test_runtime_hardening.cppasserts that protected (/etc/rc.conf.d,/usr/local/bin), non-normalized (/var/lib/../etc), and root-escape (/) paths are rejected on a BSD profile. - test(platform): BSD integrated flow — optional unavailable sandbox controls are accepted: new SCENARIO in
tests/integration/test_runtime_hardening_flow.cppevaluates a BSD profile in optional mode with optional unavailable pledge and capsicum gates and asserts both are accepted without blocking. - test(platform): BSD integrated flow — required unavailable sandbox gate fails closed: new SCENARIO in
tests/integration/test_runtime_hardening_flow.cppasserts that a required unavailablepledgegate fails closed with"required hardening gate unavailable: pledge", mirroring the Linux seccomp gate test.
0.10.5¶
Added¶
- feat(dev): add installable pre-commit project gates:
scripts/hooks/pre-commitnow runs the unsafe C/C++ source gate, Catch2 BDD style checks, unit/conformance test registration checks, and a new staged-file changelog/docs guard.scripts/install-hooks.shinstalls the tracked hook template into.git/hooks, and tooling tests cover the hook wiring and changelog/docs guard behavior. - feat(dev): add Codex post-edit clang-format hook:
.codex/hooks.jsonwires a CodexPostToolUsehook for edit/write tool calls, backed by.codex/hooks/clang_format_after_edit.py, so edited C and C++ source files are formatted withclang-format -iimmediately after Codex modifies them.
Fixed (PR review)¶
- fix(federation): discovery timeout ignores configured
remote_timeout:perform_sync_outbound_callused a hard-coded 30 s timeout for thediscover_server()call regardless of the operator-configuredsecurity.federation.remote_timeout. Operators who raised the timeout for slow federation joins (e.g. 180 s) would still see discovery fail after 30 s during the.well-knownfetch. Now passestimeout_secondsdirectly todiscover_server(). - fix(config): example
security.federation.remote_timeoutwas 600s instead of the 60s default:config/merovingian.conf.exampleshowed600s, overriding the code default and tying up caller and worker slots for ten minutes per slow join. Corrected to60s. - fix(scripts):
check-staged-changelog-docs.shmissed deleted project files:--diff-filter=ACMRexcludedD(Deleted) paths, so a commit that removes source or packaging files bypassed the changelog/docs guard. AddedDto the filter. - fix(scripts):
install-hooks.shfailed in linked worktrees:.git/hooksis not a directory in a linked worktree. Now resolves viagit rev-parse --git-path hooksandmkdir -p. - fix(packaging): RPM
%changelogentries missing for 0.10.5: Added 0.10.5 entries topackaging/rpm,packaging/rhel, andpackaging/opensusespec files.
Added tests¶
- test(federation): outbound HTTP routing through worker IPC — coverage for
WorkerPool::send_outbound_request,FederationProxy::send_outbound_request, and theoutbound_http_requestbranch inWorkerEventLoop::run:tests/integration/test_federation_worker_flow.cppgains four new scenarios: healthy-pool outbound dispatch to a quick-failing (ECONNREFUSED) pinned address across both shards; stopped-pool early-exit returning a non-empty error detail; andFederationProxy-level outbound dispatch proving the proxy delegates correctly to the pool.
Fixed¶
- fix(federation): federation join/leave 502 failures logged at DEBUG instead of WARN:
perform_sync_outbound_calland the three 502-pathlog_diagnosticcalls forroom.join.rejected(send_join timeout) androom.leave.rejected(make_leave/send_leave timeout) all defaulted toLogEventSeverity::debug. A timed-out join to a busy room produced no visible log entry at INFO level. The outbound failure path insideperform_sync_outbound_calland all three 502 rejection sites are now promoted toLogEventSeverity::warnper the log-level policy. - fix(federation):
security.federation.remote_timeoutconfig ignored for join/leave outbound calls:perform_sync_outbound_callconstructedOutboundCallwithout settingconnect_timeout_secondsortotal_timeout_seconds, so both fell back to the hardcoded struct defaults (connect=10 s, total=60 s) regardless of what the config said. The function now accepts atimeout_secondsargument and sets both fields (connectcapped at 30 s). All four callers —make_join,send_join,make_leave, andsend_leave— now passruntime.federation.config.remote_timeout_seconds. The config default forsecurity.federation.remote_timeoutis raised from"30s"to"60s"to match the old hardcoded value; operators joining large rooms (e.g.#twim:matrix.org) should set this to"180s"or higher. - feat(federation): join/leave outbound HTTP calls routed through the federation worker:
perform_sync_outbound_callnow signs the X-Matrix Authorization header in the main process (Ed25519 secret never crosses the IPC boundary) and then routes the resultingOutboundRequestthroughFederationProxy::send_outbound_request→WorkerPool::send_outbound_request→ IPCoutbound_http_requestframe → worker thread pool. The worker executes the HTTP call viaOutboundClient::performand returns the result via IPCoutbound_http_responseframe. This prevents long-running federation joins (e.g. to busy rooms with 180 s timeouts) from blocking main-process HTTP handler threads. The IPC channel timeout is set tototal_timeout_seconds + 10 sto give the worker time to return before the IPC side declares a timeout. Theoutbound_http_requesthandler is wired inWorkerEventLoop::runalongside the existingfed_requesthandler.
0.10.4¶
Changed¶
- feat(federation): federation worker is now mandatory: the
federation.worker.enabledandfederation.worker.fallback_in_processconfiguration keys have been removed. Themerovingian-fed-workerprocess is the only supported federation path; startup fails with a fatal error if the worker cannot be launched.FederationProxyis fail-closed with no in-process fallback. Config validation now always enforces non-zero values forfederation.worker.shards,federation.worker.threads, andfederation.worker.request_timeout_seconds.
Fixed¶
- fix(federation): TOCTOU channel race in
WorkerSupervisor: the supervisor restart loop could resetchannel_(aunique_ptr) while a request thread was between thehealthy()check and thechannel().send_request()call, causing a use-after-free.channel_is now ashared_ptrprotected bychannel_mu_; external callers obtain a ref-counted snapshot viachannel_snapshot()that remains valid across concurrent restarts. - fix(federation):
room_id_from_send_bodyfailed when nested JSON objects precederoom_id: the old search stopped at the first}inside the first PDU object, which belongs to a nestedcontent,hashes, orunsignedfield rather than the PDU itself. The extractor now tracks brace depth (including string-escaped characters) to correctly identify the PDU closing brace, then searches within the full PDU span. - fix(federation): spurious sync wakeup after PDU ingestion:
publish(next_stream_ordering)was called with the value ofruntime_.database.next_stream_orderingafterpdu_sinkhad already incremented it, waking sync clients with a token one step ahead of the event. The stream ordering is now captured before invokingpdu_sink. - fix(config): zero threads and zero request timeout were not validated:
federation.worker.threads=0andfederation.worker.request_timeout_seconds=0were only rejected when the worker was enabled. Since the worker is now always enabled, both values are always validated and rejected if zero.
Added tests¶
- test(federation): BDD scenarios for nested-object PDU room ID extraction:
tests/unit/test_federation_request_routing.cppadds two new scenarios verifying thatroom_idis correctly extracted from/sendbodies wherecontent+hashesobjects and deeply nestedrelates_tocontent precede theroom_idfield. - test(config): mandatory-worker validation scenarios:
tests/unit/test_config_parser.cppadds scenarios asserting that zero threads and zero request timeout are always rejected; removed the now-invalid "accepts zero shards when disabled" scenario.
CI¶
- fix(ci): OpenSUSE Tumbleweed RPM build fails on cold cache:
hendrikmuhs/ccache-action@v1.2does not recognise theinstallparameter added in later releases and has no zypper support, so on a cold runner it attempts auto-installation and fails. The opensuse-rpm job now usesactions/cache@v4to restore/save~/.ccachedirectly and a shell step to wire ccache viaCC=ccache clang/CXX=ccache clang++inGITHUB_ENV, replacing the ccache-action entirely for this job. The earlier symlink +GITHUB_PATHapproach was dropped because appending toGITHUB_PATHinside a Docker container step corrupts thePATHseen by subsequent steps, leavingshunfindable at exec time.
0.10.3¶
Added¶
- feat(federation): room-sharded federation workers (Phase 3): inbound federation requests are now routed across N independent
merovingian-fed-workerprocesses usingfederation.worker.shards. Room-scoped endpoints are assigned byfnv1a_32(room_id) % shards; non-room endpoints (key queries, profile queries, etc.) route to shard 0. Each shard has its ownWorkerSupervisorrestart monitor and encrypted AF_UNIX IPC channel, so a CPU-heavy room can no longer starve federation traffic for all other rooms.FederationProxyextracts the room ID from request targets and from the first PDU inPUT /send/{txnId}bodies.
Changed¶
- refactor(federation):
WorkerPoolowns N supervisors:FederationProxynow delegates to aWorkerPoolthat createsfederation.worker.shardsWorkerSupervisorinstances. Each worker is launched with--shard <index>so log output can identify the shard; the per-worker IPC request handler captures the correct supervisor reference sopdu_ingestandsign_requestresponses are sent on the channel that received them.
Fixed¶
- fix(federation): close the server-side IPC fd before duping the client fd in
WorkerSupervisor: theposix_spawnfile actions previously duplicated the child's socketpair end ontokWorkerIpcFd(3) and then closed the parent-side fd. Whensocketpairreturned the parent side as fd 3, the close removed the newly placed IPC fd, causing the worker to fail with "ipc fd 3 is not open". The close now happens before the dup2 so the fixed IPC fd survives into the child. - fix(federation):
WorkerSupervisor::healthy()reports healthy beforestart(): a constructed supervisor that has not yet spawned a worker is not failed;healthy()now returnstruewhen no IPC channel exists yet, so pre-start health checks and unit assertions behave correctly. - fix(ipc):
deserialize_fed_responseparses numericstatusfield: the worker response frame carriesstatusas a JSON number, but the deserializer was reading it with the string helper and defaulting to500. A dedicated numeric parser now returns the actual HTTP status from the worker. - fix(packaging): include
merovingian-fed-workerin all binary package manifests: the OpenSUSE, RHEL, Fedora RPM specs and the OpenBSDPLISTnow list the new federation worker helper binary. Package builds that fail on unpackaged files will now succeed. - fix(build): include
<charconv>insrc/homeserver/worker_pool.cppand avoidstd::istreambuf_iteratorin the federation worker: FreeBSD/Clang and RHEL/GCC builds failed becausestd::from_charswas used without the required header and because GCC 14 warned on the iterator-based file read. A chunked read loop and the missing include make the worker build cleanly on those compilers. - fix(ipc): close the IPC file descriptor only after the reader thread has joined:
IpcChannel::stop()previously closed the descriptor and then joined the reader, so ThreadSanitizer saw a data race betweenFileDescriptor::reset()writingm_fdandFileDescriptor::get()reading it from the reader thread. The shutdown sequence now wakes pending waiters, calls::shutdown()to unblockrecv(), joins the reader, and only then closes the descriptor underwrite_mu_. -
fix(ipc):
IpcChannel::stop()reports the channel as unhealthy:healthy()continued to returntrueafter an explicitstop()because the flag was only cleared on reader errors.stop()now clearshealthy_so callers can distinguish a deliberately stopped channel from a live one. -
fix(federation): X-Matrix auth lost across IPC:
serialize_fed_requestomittedaccess_token, which carries the rawAuthorization: X-Matrix …header value. Every federation request proxied to a worker arrived unauthenticated. The field is now serialized and restored on deserialization. - fix(platform): add
execve/execveatto the seccomp allowlist:posix_spawncallsexecveatin the child process, which inherits the seccomp filter. Both syscalls were absent from the BPF allowlist, so all worker spawns (initial and restart) were killed by the filter. The worker installs its own stricter filter after startup, so the main server's exposure is bounded. - fix(federation): graceful-shutdown watcher deadlock: after receiving a
"shutdown"notification the IPC channel stays healthy, so the watcher loop ran forever andwatcher.join()blocked indefinitely. The watcher now also checks theshutdownatomic and exits promptly. - fix(federation):
healthy_not restored afterWorkerSupervisorrestart: the supervisor sethealthy_tofalsewhen a worker died but never set it back after a successful respawn, permanently excluding that shard from routing. The flag is now reset totrueon a successful restart. Thefalsestore is also moved to beforechannel_.reset()to close a TOCTOU window where a nullchannel_pointer could be dereferenced by a concurrent request thread. - fix(federation): missing v2 shard-routing prefixes:
/_matrix/federation/v2/invite,send_join,send_leave,make_knock, andsend_knockwere absent from the shard-routing prefix table, so all v2 membership requests landed on shard 0 regardless of the room they targeted. - fix(ipc): IPC frame cap raised from 4 MiB to 50 MiB: large federation transactions (many PDUs or large auth chains) could exceed 4 MiB, causing the frame to be silently dropped. The cap is now 50 MiB, matching common homeserver defaults.
Added tests¶
- test(federation): BDD scenarios for federation worker shard routing:
tests/unit/test_federation_proxy.cppcovers deterministic FNV-1a sharding, single-shard fallback to shard 0, non-room request routing, and the 503 fail-closed path when the selected shard's worker is unhealthy. - test(federation): exhaustive unit coverage for Phase 2/3 helpers:
tests/unit/test_federation_request_routing.cpp,tests/unit/test_ipc_federation_frames.cpp,tests/unit/test_federation_worker_args.cpp,tests/unit/test_worker_supervisor.cpp, andtests/unit/test_worker_event_loop.cppcover room-ID extraction from federation paths and/sendbodies, IPCfed_request/fed_responseframe round-trips, worker CLI argument parsing, and shard-index capture in supervisor/event-loop construction. - test(config): federation worker shard validation:
tests/unit/test_config_parser.cppasserts thatfederation.worker.shardsis parsed, thatshards=0is rejected when the worker is enabled, and thatshards=0is accepted when the worker is disabled. - test(federation): end-to-end worker flow integration:
tests/integration/test_federation_worker_flow.cppspawns the realmerovingian-fed-workerbinary, waits for the pool to become healthy, routes non-room and room-scoped requests through the IPC channel, verifies shard selection, exercises in-process fallback when the pool is stopped, and routes room-scoped state requests and/sendtransaction bodies through the IPC channel. - test(ipc): expanded unit coverage for IPC framing and Ed25519 provider error paths:
tests/unit/test_ipc_framing.cppandtests/unit/test_ipc_federation_frames.cppnow cover control-character JSON escaping, invalid HTTP status normalization, empty-body request round-trips, skipped headers, concurrent request/response pairing, stopped-channel behavior, andIpcEd25519Providererror handling for null channels, unexpected response types, malformed signatures, and main-side signing errors. - test(federation):
access_tokenIPC round-trip:test_ipc_federation_frames.cppnow asserts thataccess_tokensurvivesserialize_fed_request/deserialize_fed_requestso X-Matrix auth is not silently dropped. - test(federation): v2 endpoint shard routing:
test_federation_request_routing.cppnow covers all five/_matrix/federation/v2/membership prefixes to verify the correct room ID is extracted for shard selection.
0.10.2¶
Added¶
- feat(federation): sign-back channel for the out-of-process federation worker (Phase 2): the
merovingian-fed-workerchild no longer loads the Ed25519 server signing secret. When the worker needs to sign JSON (co-signing invites,/_matrix/key/...responses, etc.), it sends asign_requestIPC frame to the main process, which signs with its in-memory provider and returns the unpadded base64 signature. The private key never crosses the IPC boundary. NewIpcEd25519Providerimplements thecrypto::Ed25519Providerinterface over the encrypted channel; the main process handlessign_requestinFederationProxyand returnssign_response.
Changed¶
- refactor(runtime): centralise runtime signing provider:
HomeserverRuntimenow owns acrypto::Ed25519Provider*(crypto_provider) used bypublish_server_signing_keys(),compose_signed_event(), invite co-signing, and outbound join/leave signing.find_active_server_signing_key()selects the active public key record without decrypting the secret. Worker startup injectsIpcEd25519ProviderviaRuntimeStartOptions::signing_override; the main process uses the persisted production provider.
Added tests¶
- test(ipc): BDD scenario for
IpcEd25519Providerround-trip:tests/unit/test_ipc_framing.cppcovers a worker-side sign call being routed over the encrypted channel and returning the expected signature bytes.
0.10.1¶
Added¶
- feat(federation): out-of-process federation worker (
merovingian-fed-worker): federation CPU and I/O work (inbound PDU verification, state resolution, membership state machine, outbound dispatch) now runs in a dedicated child process with its own thread pool, so the main process threads are exclusively available for client-server API requests. A large room join no longer starves all connected clients. The IPC channel uses asocketpair(AF_UNIX)inherited file descriptor (no filesystem socket path), an ephemeralcrypto_kxkey exchange, andcrypto_secretstream_xchacha20poly1305encryption for all frames. Client access tokens are stripped before forwarding; the worker receives only the validated user MXID.
0.9.25¶
Fixed¶
- fix(observability): eliminate duplicate fields in structured log lines: all 42 modules were using
LOG_DEBUG(diagnostic_log_summary(...)), which produced lines with the level word appearing twice (<DEBUG> ... debug module event=...) and the real module name hidden behind the wrapper function namelog_diagnostic:. The publiclog_diagnosticfree-function inlogger.hppnow calls the appropriate named method (debug(),info(), etc.) using the logger name as the module, so the line format is<LEVEL> <module>: event=<event> key=value ...with no redundancy. All 42 modules migrated to useobservability::log_diagnostic()directly. - fix(observability): missing timestamp on auth diagnostic lines:
auth_service.cppwas the only module calling the publicobservability::log_diagnostic()directly, which passed a raw summary string toSingleLog::log()without going throughmake_log_line(). The same fix above routes all calls through the named methods, restoring the timestamp header on every log line.
Changed¶
- feat(observability): promote major lifecycle events to INFO level: the following events are now logged at INFO rather than DEBUG so operators see them without enabling verbose logging:
login.accepted,logout.accepted,logout_all.accepted,registration.accepted(auth);room.create.accepted,room.join.accepted,room.join.accepted_remote,room.leave.accepted(rooms);signing_key.loaded,signing_key.generated,signing_key.rotated(crypto);migration.step.applied,migration.plan.complete(database);tls.context.ready,start.listeners_ready,start.database_ready,start.hardening_controls,start.complete,database.hydrated(server startup — already INFO, now correctly formatted). Thedatabase.state.repairedwarning is also correctly emitted at WARNING via the structured path rather than the oldLOG_WARNING(diagnostic_log_summary(...))pattern.
0.9.24¶
Fixed¶
- fix(client-server): re-acquire homeserver mutex after alias directory lookup in
POST /join/{roomIdOrAlias}: when joining a room by alias, the handler unlocked the homeserver guard before the outbound federation directory query (/_matrix/federation/v1/query/directory) but never re-locked it afterwards. Any subsequent call intocall_localinvokedguard.unlock()on an already-unownedstd::unique_lock, throwingstd::system_error(EPERM). The exception was swallowed by the thread-pool worker (action=swallowed, type=St12system_error, what=Operation not permitted), silently aborting the join. The fix adds a singleguard.lock()immediately afterperform_sync_outbound_callreturns, before processing the response body. Joining by room ID (which bypasses the alias lookup path) was unaffected.
Added¶
- test(client-server): BDD regression coverage for
POST /joinvia room alias guard unlock bug:tests/unit/test_client_server.cppadds a scenario asserting that joining via a#alias:serverform succeeds without throwing and returns the expected room ID.
0.9.23¶
Fixed¶
- fix(federation): start the outbound dispatch worker eagerly at server startup instead of lazily on the first join request:
DispatchWorker::start()was called insidewire_federation_callbacks_impl(), which was itself wired lazily on the first client-server request that needed federation. On Fedora/RHEL deployments withTasksMax=set,pthread_createreturnedEPERMand the resultingstd::system_errorpropagated through the thread-pool worker, killing the worker and leaving federation permanently broken for that request. The fix has three parts: (1)DispatchWorker::start()now resets thestarted_flag on exception so a retry is possible; (2)wire_federation_callbacks_impl()catchesstd::system_errorfromstart(), logs the failure, and resets the worker instead of propagating; (3)main.cppcallswire_federation_callbacks()eagerly immediately after the runtime reaches its final stable location so thread-creation failures surface at startup as a clear log entry rather than inside a request handler. - fix(rooms): auto-write
m.directaccount data whenPOST /createRoomis called withis_direct:true: when a DM room was created from one device (e.g. Element), the Matrix spec requires the client to subsequentlyPUTthem.directglobal account data mapping the invitee to the new room. If thatPUTnever arrived — because the client manages it locally, or the server dropped it — a second device (e.g. ElementX) would see nom.directentry and itsis_dm:truesliding-sync list would return empty, causing it to create a duplicate DM room instead of showing the existing one.create_room()now calls a newupsert_m_direct()helper immediately after emitting the invite events: it reads any pre-existingm.directmapping, appends the newroom_idunder each invitee's key (without duplicating), serialises, and persists viadatabase::store_account_data(). Rooms created withoutis_direct:trueare unaffected.
Added¶
- test(rooms): BDD coverage for
m.directupsert on DM room creation:tests/unit/test_homeserver_room_service.cppgains three scenarios —m.directwritten when absent, second room appended without duplication, and non-DM room creation leavesm.directuntouched.
0.9.22¶
Fixed¶
- fix(client-server): implement
POST /_matrix/client/v3/pushers/setso Element X stops showing "route not found" when registering for push notifications: the endpoint was unimplemented and returned404 M_UNRECOGNIZED, causing Element X to report that it could not receive notifications. The handler now validates the request body per Matrix v1.18 (requiredapp_id,pushkey, andkind; additionalapp_display_name,data,device_display_name, andlangfor non-null kinds; HTTPS/_matrix/push/v1/notifyURL for HTTP pushers) and returns200 {}. Merovingian still does not deliver push notifications, but clients can now register without error.
Added¶
- test(client-server): add BDD coverage for
POST /_matrix/client/v3/pushers/set:tests/unit/test_client_server.cppexercises valid HTTP pusher registration, pusher deletion (kind:null), missing required fields, non-HTTPS URL rejection, missing notify-path rejection, and unauthenticated rejection.tests/conformance/test_client_server_conformance.cppasserts the spec-required200 {}response shape for both registration and deletion.
0.9.21¶
Fixed¶
- fix(sync): emit explicit m.typing stop events so typing notifications can restart after a user stops typing: previously
/synconly emitted anm.typingephemeral event when the current list of typing users was non-empty. A stop-typing request removed the user from the list but sent nothing, so clients that replace their typing knowledge only on received events still believed the user was typing; the next start-typing event therefore appeared unchanged and clients did not surface a new notification. The server now tracks a per-roomroom_typing_stream_idcursor that advances whenever the set of typing users changes, and/sync(and the MSC4186 typing extension) emits the current list for that room on every change, including an emptyuser_idsarray when the user stops. This also fixes transitions where one of several typing users stops but another continues, because the response now carries the full current list rather than only users whose individual cursor is newer thansince.
Added¶
- test(sync): typing stop/restart regression coverage:
tests/integration/test_client_server_flow.cppaddsSCENARIO("Typing notifications can stop and restart via ephemeral events in /sync"), which asserts start -> non-empty list, stop -> empty list, restart -> non-empty list.
0.9.20¶
Fixed¶
- fix(database): persist sync_stream_watermark so sync stream IDs cannot roll back across restart:
sync_stream_idis the monotonically-increasing third component of the/syncstream token triplet and is used for to-device messages, device-list changes, presence, global account data, room account data, typing notifications, and read receipts. In-memory-only sync surfaces (rt.homeserver.typing_usersandrt.homeserver.receipts) previously advanced the counter without persisting it, so a server restart could resetnext_sync_stream_idto a value the client had already seen. This caused/synclong-polls to skip new ephemeral events and, in some clients, to return an emptyrooms.join. A newsync_stream_watermarktable stores the highest allocated ID, anddatabase::allocate_sync_stream_id()atomically increments the in-memory counter and persists it before returning the ID. Fresh installs bootstrap schema version1and apply migration002_sync_stream_watermark.sql; existing version-1deployments migrate cleanly to version2. - fix(sync): ensure ephemeral typing and receipt events advance the persistent sync stream counter: the
PUT /typing/{userId}endpoint, receipt handling, and account-data/account-data paths previously advancedrt.database.next_sync_stream_iddirectly, which was both un-persisted and leaked counter-management details across modules. They now calldatabase::allocate_sync_stream_id(store)so every typing notification, read receipt, and account-data write bumps the durable watermark, keeping the ordering stable across restarts. - fix(sync): deliver typing notifications to
/syncrecipients after homeserver restart: because the sync stream counter could roll back after restart, a typing event written after restart could carry an ID less than or equal to the recipient'ssincetoken. The/syncresponse builder therefore ignored it and the recipient saw nom.typingephemeral event. With the watermark in place, allocated IDs are always strictly greater than any ID previously returned to clients, so typing notifications are delivered correctly after restart. - fix(database): seed the v2 migration watermark from the highest persisted sync_stream_id: migration
002previously createdsync_stream_watermarkwith the default value'0', so an existing database whose in-memory counter had advanced via typing/receipts would start the counter from the max of the persisted surfaces after restart. The migration now insertsCOALESCE(MAX(CAST(stream_id AS INTEGER)), 0)acrossaccount_data,room_account_data,to_device_messages,device_list_changes, andpresence_stateas the initial watermark. - fix(sync): advance the sync stream counter when a client's since-token is ahead of the server: if the
/syncsincetoken'ssync_stream_idexceedsstore.next_sync_stream_id,sync_json()now callsdatabase::ensure_sync_stream_id_ahead_of()to advance the counter (and persist the watermark) to the client's position. This recovers live deployments whose counter rolled back below a client's stored token — the next typing notification or receipt gets an ID strictly greater than the token and is delivered.
Added¶
- test(database): add regression coverage for sync stream watermark persistence:
tests/unit/test_database_persistence.cppaddsSCENARIO("Sync stream watermark prevents sync-stream id rollback across SQLite restart"), which writes a watermark viaallocate_sync_stream_id(), re-opens the SQLite persistent store, and asserts the restored counter is greater than the pre-restart value. - test(sync): add typing notification delivery regression test:
tests/integration/test_client_server_flow.cppaddsSCENARIO("Typing notifications are delivered via ephemeral events in /sync"), which creates a room, starts typing, waits for the recipient's/syncto return them.typingephemeral event, and asserts it contains the sender. - test(database): add regression coverage for counter rollback recovery:
tests/unit/test_database_persistence.cppaddsSCENARIO("ensure_sync_stream_id_ahead_of recovers from a counter rollback"), which simulates a client since-token ahead of the persisted watermark, advances the counter, and asserts the next allocated ID exceeds the token.
0.9.19¶
Fixed¶
- fix(sync): stop ElementX sliding-sync loop caused by repeated room re-inclusion: MSC4186
/_matrix/client/unstable/org.matrix.msc4186/syncnow tracks the last stream ordering at which each room was returned per connection (SlidingSyncConnectionState::rooms_seenis astd::unordered_map<std::string, std::uint64_t>). The per-room delta floor ismax(request_since, per_room_last_inclusion), so a room is not pulled back intorooms{}by unread counts alone. Unread counts are now sent only when the room is already included because its timeline orrequired_statechanged. - fix(sync): wake MSC4186 sliding sync long-poll on typing and read receipts in joined rooms: the spurious-wakeup suppression logic only considered device-list changes, to-device messages, and account-data as relevant
sync_stream_idadvances. Typing notifications and read receipts in the user's joined rooms advanced the stream id without waking the long-poll, so ElementX did not receive live updates or conversation changes. The relevance check now also scansrt.homeserver.receiptsandrt.homeserver.typing_usersscoped to joined rooms. Addedrooms_in_response,rooms_skipped, androoms_windowfields to thesliding_sync.responsediagnostic log, plus BDD unit coverage for both the repeated-poll suppression case and the typing wake-up case.
0.9.18¶
Added¶
- feat(client-server): implement Matrix room tag endpoints:
GET/PUT/DELETE /_matrix/client/v3/user/{userId}/rooms/{roomId}/tags[/{tag}]is now wired for joined members, storing tags as per-user/per-roomm.tagroom account data.PUTaccepts an empty body or{ "order": <double> };DELETEremoves a tag;GETreturns the spec-shaped{ "tags": { "<tag>": { ... } } }map. - feat(canonicaljson): add general JSON parser/serializer with
doublesupport:canonicaljson::parse_json()parses arbitrary Matrix client JSON (using yyjson) into a newcanonicaljson::Value::double_alternative, and the serializer emits doubles with shortest round-trippable output. This lets room account data such asm.tagcontain non-integer values without breaking the signing-focused canonical JSON pipeline, which still rejects non-integer numbers.
Fixed¶
- ci: fix coverage and sanitizer jobs on the new branch:
gcovrnow runs with--gcov-ignore-parse-errorsso clang-generated branch-heavy.gcovoutput forclient_server.cppno longer aborts coverage report generation. The ASan/UBSan job now setsMESON_TEST_TIMEOUT_MULTIPLIER=3so the seccomp/SQLite integration test is not killed by the default 600 s timeout under sanitizer-induced slowdown.
0.9.17¶
Added¶
- feat(homeserver): implement Matrix space hierarchy endpoints:
GET /_matrix/client/v1/rooms/{roomId}/hierarchynow returns a paginated, depth-first list of rooms in a space tree, honouringmax_depth,suggested_only, andlimit, with URL-safe base64 pagination tokens.GET /_matrix/federation/v1/hierarchy/{roomId}is also wired so remote servers can fetch a space summary. Both endpoints use the local persistent state (m.space.child,m.room.create,m.room.join_rules) and apply visibility rules before exposing rooms. - feat(homeserver): implement Matrix v1.18 event relations endpoints:
GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}[/{relType}[/{eventType}]]is now wired for joined members. The endpoint scans local room events form.relates_toreferences to the parent event, optionally filters byrel_typeand childevent_type, supportsdir,from,to,limit, andrecursequery parameters, and returns a spec-shaped paginatedchunkwith optionalnext_batch,prev_batch, andrecursion_depthfields. Encrypted poll responses that relate viam.referenceare now returned asm.room.encryptedevents, fixing Element polls that previously failed with404 M_UNRECOGNIZED.
Fixed¶
- fix(media): encrypted-room attachments no longer quarantined on upload and authenticated
POST /_matrix/client/v1/media/uploadis wired: E2EE clients (Element/Web) upload encrypted attachments as opaqueapplication/octet-streamciphertext, which was missing from the defaultsecurity.media.allowed_mime_typesallow-list and caused uploads to be quarantined; downloads of those quarantined files later returned451 Unavailable For Legal Reasons. The default allow-list now includesapplication/octet-stream, and operators can override the list via the newsecurity.media.allowed_mime_typesconfiguration key. The authenticated v1 upload endpoint was also absent from the client-server dispatcher and has been added, using the same raw-binary-to-pipe-format translation as the unauthenticated v3 path. - fix(config): example configuration now includes all recognised keys:
config/merovingian.conf.examplewas missingsecurity.access_token_lifetime_ms,security.refresh_token_lifetime_ms,security.media.allowed_mime_types,security.media.remote_fetch_enabled, andsecurity.secrets.master_key_file. Each is now documented with its default and, where relevant, the security implications of changing it.
0.9.16¶
Fixed¶
- fix(media): media download and thumbnail endpoints no longer 404 on query parameters and now return raw bytes with Content-Type:
GET /_matrix/media/v3/download/{serverName}/{mediaId},GET /_matrix/client/v1/media/download/{serverName}/{mediaId}, and the equivalent thumbnail routes failed when clients appended?allow_redirect=true(or?width=...&height=...) becauselocal_media_download_partsparsed the query string as part of the media ID. The helper now strips the query component before splittingserver_name/media_id. Additionally, successful download/thumbnail results were returned in the internalcontent_type|bytespipe format to clients instead of the raw bytes the Matrix spec requires;client_server.cppnow splits that payload and emits aContent-Typeheader.
0.9.15¶
Added¶
- test(events): add BDD coverage for event_signer.cpp — new
tests/unit/test_event_signer.cppexercisessigning_key_id_is_valid(valid, empty server_name, empty key_id, control chars, space, DEL),matrix_base64_from_bytes/matrix_bytes_from_base64(round-trip, empty input, invalid base64),make_event_signing_payloadwithout and withRoomVersionPolicy(success and non-object error paths),attach_event_signature(success, invalid key ID, short-decoded signature, non-object event),sign_event_for_server(success, non-object event, no key for server),verify_event_signature_presence(invalid key ID, non-object, missing signatures, missing server), andverify_event_signature(end-to-end success, provider-failure path). - test(crypto): expand signing_service.cpp coverage — six new scenarios appended to
tests/unit/test_crypto.cpp:signing_key_record_is_usabledirectly tested for active, inactive, empty server_name, non-Ed25519 key_id, and wrong-size public key;sign_for_serverwith empty server name ("server name is empty"), key store error propagation, server-name mismatch ("active signing key server mismatch"), provider error propagation, and provider returning invalid signature shape ("provider returned invalid Ed25519 signature shape"). - test(media): expand media/security.cpp coverage — eleven new scenarios appended to
tests/unit/test_media_security.cpp:media_disposition_namefor all three enum values,media_mime_type_is_alloweddirect call with allowed and unlisted types,evaluate_media_uploadfor zero size limit, zero byte_size, missing content sniff result, reject (not quarantine) for unknown MIME withquarantine_unknown_mime=false, and reject (not quarantine) for scanner failure withquarantine_scanner_failures=false;remote_media_fetch_policyfor invalid origin server and empty resolved_host, and SSRF-blocking-disabled path;admin_quarantine_policyfor invalid admin user ID and invalid media ID;evaluate_decoder_safetyfor input bytes exceeding the limit and pixel count exceeding the limit. - test(auth): expand key_api.cpp coverage — five new scenarios appended to
tests/unit/test_key_api.cpp:key_payload_is_loggable("")andredacted_key_payload_summary("")for the empty-payload branch;key_api_endpoint_nameexhaustive coverage for all 19 enum values confirming no "unknown" fallback;match_key_api_routeno-match cases (wrong path, wrong method); full GET and DELETE backup-route matching for version-by-id, room-key batch, room-key-by-session, room-key-by-room, and delete-version;key_api_database_statementsfor update, delete, get backup-version, claim-keys, and upload-signatures endpoints. - test(database): add BDD coverage for schema identifier quoting, core-table introspection, migration-step validation, and migration-plan validation error paths:
tests/unit/test_database_schema.cppexercisesquote_sqlite_identifierrejection of empty inputs and SQL-injection characters (semicolons, embedded quotes, spaces),schema_table_is_coreandschema_table_definitionfor known vs unknown table names,create_table_sqlDDL generation and non-core rejection,current_schema_versionnon-zero invariant,initial_schema_tablesnon-empty invariant,migration_step_is_validrejecting version-zero upgrade steps, hyphenated names, empty statement lists, and multi-statement SQL,migration_direction_nameround-trips,migration_rollback_policynon-empty policy string,migration_plan_betweensame-version no-op and beyond-catalog empty-plan cases,apply_migration_planstate-version-mismatch failure, andmigration_plan_is_validcovering no-op-with-steps rejected, upgrade-with-no-steps rejected, direction-mismatch rejected, and catalog-derived plan accepted. - test(homeserver): add BDD coverage for authentication and HTTP-dispatch failure paths:
tests/unit/test_homeserver_error_paths.cppexercises login with wrong password, login for unknown user, duplicate username registration, logout with unknown/empty token,verify_local_user_passwordwith wrong password,handle_local_http_requestreturning 4xx for unrecognised routes,handle_local_http_requestreturning 401 for auth-required routes with no access token, andhandle_federation_http_requestreturning 4xx for non-federation paths. - test(homeserver): add BDD coverage for auth_service functions not previously tested:
tests/unit/test_homeserver_auth_service.cppexercisesbootstrap_admin_usersuccess and duplicate rejection, admin privilege confirmed viaauthenticated_admin_userand denied for regular users,account_state_for_userreturning nullopt for unknown/empty user IDs andAccountState::activefor new registrations,logout_all_local_userrejecting unknown/empty tokens and revoking all sessions,change_local_user_passwordrejecting unknown tokens and verifying old token invalidation + old password rejection + new password acceptance,delete_local_devicerejecting unknown user and unknown device and confirming session invalidation on valid deletion,issue_refresh_token_for_sessionfailing for unknown users,refresh_local_sessionrejecting empty/unknown tokens, issuing new tokens on valid exchange, and enforcing single-use, andaccess_token_is_soft_logoutreturning false for empty and unknown tokens. - test(homeserver): add BDD coverage for room_service operations not previously tested:
tests/unit/test_homeserver_room_service.cppexercisesban_userrejecting unauthenticated callers, non-existent rooms, and non-privileged members; confirming the room creator can ban a member;kick_userrejecting unauthenticated callers and non-privileged members; confirming the room creator can kick;unban_userrejecting unauthenticated callers and confirming the room creator can lift a ban;forget_roomrejecting unauthenticated callers, rejecting forget-while-joined, and succeeding after leave;knock_roomrejecting unauthenticated callers and non-existent rooms. - test(federation): add BDD coverage for server-name validation boundaries and PDU envelope parsing error paths:
tests/unit/test_federation_server_name.cppexercisesserver_name_is_validaccepting valid domains and domain-with-port, rejecting empty strings, dot-free single-label names, names exceeding 255 characters, names with embedded spaces, newlines, and tabs;federation_discovery_policyrejecting unresolved hosts, empty address sets, invalid server names, and TLS-not-required remotes;parse_inbound_pdu_envelopereturning nullopt for empty input, JSON arrays, JSON string literals, and malformed JSON.
0.9.14¶
Fixed¶
- fix(client-server): implement
GET /_matrix/client/v3/rooms/{roomId}/initialSyncfor room previews and stop Element Web "route not found" errors: this endpoint is deprecated for general syncing but is still used by Element Web to preview public rooms before joining. The route was previously unimplemented, returning404 M_UNRECOGNIZEDand causing an uncaught promise rejection when selecting a room from the directory. The handler now returns the spec-shapedRoomInforesponse (room_id,membership,messages,state,visibility,account_data) for current and previous members, and allows non-member peeking when the room'sm.room.history_visibilitystate isworld_readable. Non-peekable or non-resident rooms return403 M_FORBIDDENso the client can fall back to joining; the spec only defines 200/403 for this endpoint. - fix(media): media uploads larger than 1 MiB no longer rejected at HTTP parse layer with CORS-less 413:
parse_request_headcheckedContent-Lengthagainst a hard-coded 1 MiB default before the request reachedhandle_client_server_request, so any upload between 1 MiB and the configuredmax_upload_size(default 100 MiB) was refused at the transport layer with a 413 response that carried noAccess-Control-Allow-Originheader — causing browsers to misreport it as a CORS error. The body-size check is removed from the parser (syntax validation only) and moved intoserve_stream, where the target URL is available:POST /_matrix/media/v3/uploadandPOST /_matrix/client/v1/media/uploadnow use the configuredmax_upload_sizecap; all other routes use the smaller general cap. When the cap is exceeded the 413 response now includesAccess-Control-Allow-Originderived from the requestOriginheader and the configured CORS policy, so browsers display the real 413 status rather than a spurious CORS error.
Added¶
- test(client-server): add BDD coverage for
GET /_matrix/client/v3/rooms/{roomId}/initialSync:tests/unit/test_client_server.cppnow exercises the new route for the creating member, a non-member in aworld_readablepublic room (peek allowed), a non-member in a private room (403 M_FORBIDDEN), an unknown/non-resident room (403 M_FORBIDDEN), and the?limit=query parameter. - test(http): add BDD scenario verifying parser accepts large Content-Length without error:
test_http_request.cppnow includes a scenario asserting thatPOST /_matrix/media/v3/uploadwithContent-Length: 2097152(2 MiB) parses without error, confirming the parser no longer rejects bodies above 1 MiB.
0.9.13¶
Fixed¶
- fix(tests): repair compile errors in 3PID test suite introduced in v0.9.13: five occurrences of
R"("})"}in conformance tests were parsed as raw string"}plus a stray}due to C++ raw-string early termination (the empty delimiter closes at the first)"sequence); fixed toR"("})". Two WHEN/GIVEN blocks in the integration and unit tests had missing;on string-concatenation declarations, causing};on the next line to close the enclosing scope prematurely and make all subsequent variables undeclared; fixed by adding the missing semicolons and re-indenting action code inside the WHEN blocks.integer_memberhelper calls in conformance tests renamed to the correctint_member. Integration test assertion forbob_duplicate_addrelaxed from== 400Uto!= 200Usince UIA correctly rejects with 401 before the duplicate check is reached. Unit test assertion that theM_SESSION_NOT_VALIDATEDerror body contains thesidvalue removed — the Matrix spec does not require the sid to be echoed in error bodies. - fix(client-server): remote room aliases now resolve over federation for room-directory lookups and joins:
GET /_matrix/client/v3/directory/room/{roomAlias}previously only searched the local alias table, so looking up#alias:remote.example.orgreturned a misleading localM_NOT_FOUNDinstead of querying the alias-owning homeserver via federation.POST /_matrix/client/v3/join/{roomIdOrAlias}had the same gap: when given a remote room alias it rewrote directly to/rooms/{roomId}/join, treating the alias text as though it were already a room ID and never resolving the remote alias to the room ID plusviaservers required for the join. The client-server handler now federates/_matrix/federation/v1/query/directoryfor remote aliases, rewrites remote-alias joins to the resolved room ID with mergedviaservers, and includes joined-member server names in local alias responses. - fix(client-server): account 3PID management now works end-to-end: Merovingian now implements the client-account 3PID flow instead of stopping at the auth-gate workaround.
POST /_matrix/client/v3/account/3pid/email/requestTokenand.../msisdn/requestTokenremain unauthenticated per Matrix v1.18, reject identifiers already in use withM_THREEPID_IN_USE, and issue validationsidvalues. Authenticated clients can now completePOST /_matrix/client/v3/account/3pid/add,POST /_matrix/client/v3/account/3pid,POST /_matrix/client/v3/account/3pid/bind,POST /_matrix/client/v3/account/3pid/unbind, andPOST /_matrix/client/v3/account/3pid/delete, whileGET /_matrix/client/v3/account/3pidreturns the spec-shapedthreepidsarray withadded_at,address,medium, andvalidated_at.GET /_matrix/client/v3/capabilitiesnow also advertisesm.3pid_changes. - fix(media): media uploads now accept real HTTP clients (raw binary body + Content-Type header):
POST /_matrix/media/v3/uploadpreviously delegated the raw client request directly to the local media router, which expected an internal pipe-delimited body (declared_mime|sniffed_mime|scanner_clean|bytes). Real Matrix clients (Element X, Element Web) send a raw binary body with aContent-Typeheader, causing every real upload to fail with 400upload body must be declared_mime|sniffed_mime|scanner_clean|bytes. The handler now extracts theContent-Typeheader from the request and constructs the correct pipe-delimited body before forwarding to the local router. MissingContent-Typedefaults toapplication/octet-stream. The route match is also widened from an exact string check to handle the?filename=...query parameter (e.g./_matrix/media/v3/upload?filename=avatar.jpg), which previously fell through to the general body-size gate and returned 413 for any upload larger than 64 KiB. Media upload requests are now governed by the configuredsecurity.media.max_upload_size(default 100 MiB) instead of the general 64 KiB client-API body cap. Quarantined uploads (internal MIME policy, 202 from the local router) are mapped to 200 toward the client with the assignedcontent_uri, since quarantine is a server-internal moderation action not defined in the Matrix spec response contract. - fix(sync): sync pool no longer exhausted by zombie timeout=30000 connections from rapid SDK reconnects: when a sliding-sync client (e.g. matrix-rust-sdk) sends a new
timeout=30000long-poll every ~90 ms while abandoning the previous TCP connection, each parked sync-pool thread was blocked inwait_for_changefor a full 5-second poll slice before detecting the dead socket — even though the peer had already closed. With 11 new connections per second and a 5-second hold time, the 32-thread sync pool became exhausted within ~3 seconds, causing subsequent long-polls to queue behind zombie tasks or fall through to the main pool, stalling all other traffic. The fix reduces the poll-interval from 5 s to 1 s and adds a non-blockingrecv(MSG_PEEK | MSG_DONTWAIT)liveness check after each slice timeout: a return value of 0 (TCP FIN) or a connection error (not EAGAIN/EWOULDBLOCK) causes the thread to exit immediately and close the fd without building or logging a response. Steady-state thread consumption drops from ~55 to ~11 for this pattern.
Fixed¶
- fix(sync): sliding sync no-pos poll returns delta after first sync, ending the matrix-rust-sdk tight-loop: matrix-rust-sdk sends a
timeout=0(noposin URL) probe alongside everytimeout=30000long-poll to get an immediate snapshot. Before this fix the server usedsince_event_ordering=0for every no-pos request — regardless of whether the connection had been used before — re-delivering all accumulated room history (≈17 KB per cycle) on every call. The SDK received the sameposeach time, treated it as a full initial sync, and immediately re-polled, creating a tight loop at ~11 cycles per second. The fix moves the per-connection state lookup before thesincecomputation insliding_sync_json: whenposis absent but the connection already has state (rooms_seennon-empty),since_event_ordering/since_sync_stream_idfall back to the connection's last-known cursors instead of zero. First call for a fresh connection still usessince=0so the SDK receives the complete initial room list; all subsequent no-pos polls return a small delta (rooms: {}when nothing changed), causing the SDK to honour the 30-second long-poll instead of looping.
Added¶
- test(client-server): add BDD coverage for remote alias federation failures: two scenarios in
tests/unit/test_client_server.cppverify the repaired path. Remote room-directory lookups now fail as federation errors rather than false local alias misses, and alias-based joins now fail only after remote alias resolution is attempted instead of treating the alias itself as a room ID. - test(client-server): add 3PID unit, integration, and conformance coverage: unit and integration scenarios now cover the account 3PID lifecycle across request-token, UIA add, bind, list, unbind, delete, and duplicate-rejection paths. Conformance coverage now asserts the spec-shaped unauthenticated request-token behavior, UIA challenge semantics for
POST /account/3pid/add, and the requiredGET /account/3pidresponse shape. - test(sync): add BDD unit tests for sync pool zombie-connection detection: three scenarios in
tests/unit/test_sync_pool_liveness.cppvalidate therecv(MSG_PEEK | MSG_DONTWAIT)liveness mechanism using Unix socket pairs — peer-closed detection (returns 0), live-connection non-detection (returns EAGAIN/EWOULDBLOCK), and abrupt-reset detection via SO_LINGER with l_linger=0. - test(sync): add BDD unit tests for sliding sync no-pos delta behavior: two scenarios in
test_client_server.cppverify the root-cause fix — that a secondtimeout=0poll on the sameconn_idwith no intervening events returnsrooms: {}and the samepos, and that a poll after a new message event returns an advancedposand includes the new timeline event.
0.9.11¶
Added¶
- test(database): add direct coverage for media, policy, and key-backup cleanup helpers: expand unit coverage for
store_local_media/update_local_media_state/store_remote_media, directdelete_policy_rulebehaviour, and scopedkey_backup_sessionscleanup paths (delete_key_backup_session,delete_key_backup_room_sessions,delete_all_key_backup_sessions).
0.9.10¶
Fixed¶
- fix(sync): MSC4186 sliding sync long-poll no longer returns early when only another user's device keys were uploaded:
handle_key_uploadfans outPersistentDeviceListChangerows to every co-member when a user uploads keys, and each write fires the globalSyncNotifier. This woke the uploading user's own sliding sync immediately, causing matrix-rust-sdk to reset to a fulltimeout=0initial sync and loop at ~5 cycles/second. The fix adds a relevance check insliding_sync_json: when onlysync_stream_idhas advanced (no new room events), the handler inspects whether any new device-list-change, to-device-message, or account-data row is actually addressed to the waiting user. If not, it returnsneeds_waitwithsince_sync_stream_idadvanced to the current counter, so the notifier must fire again before the next wakeup. All three wait paths in the HTTP server (dispatch_local_http_request, inline blocking, sync_pool lambda) are updated to loop withcan_wait=trueafter each notifier fire, continuing if the handler re-parks. - fix(sync): incremental MSC4186 sliding sync no longer returns unchanged rooms, ending the Element X tight-poll loop: two complementary bugs caused matrix-rust-sdk to treat every incremental response as carrying new data and immediately re-poll with
timeout=0. First,build_room_responseincluded allrequired_stateevents unconditionally; it now filters to only events whosestream_ordering > since_event_orderingon incremental responses (initial syncs are unaffected). Second,sliding_sync_jsonincluded every windowed room in therooms{}object regardless of whether anything changed; rooms are now gated byhas_room_updates— a room appears inrooms{}only when it is being seen for the first time, has new timeline events, has changed required_state, or has non-zero notification/highlight counts. Together these ensure that when nothing has changed since theposthe incremental response carriesrooms: {}, which causes the client to honour the 30-second long-poll timeout instead of looping.
Added¶
- test(sync): add BDD test verifying sliding sync spurious-wakeup suppression: two scenarios in
test_client_server.cppcover the device-key-upload case: when alice uploads keys (creating DLCs only for co-members as observers),handle_client_server_request(can_wait=true)returnsneeds_waitwith an advancedsince_sync_stream_id; when bob uploads keys (creating a DLC with alice as observer), the same call returnscompletewithdevice_lists.changedpopulated. - test(sync): add BDD unit tests for
build_room_responseincremental filtering: six scenarios intests/unit/test_sliding_sync_room_builder.cppcover the initial-vs-incrementalrequired_statefiltering: all matching state included on initial sync; state predating the pos omitted on incremental; only post-pos state included when mixed; wildcard*/*filter respects the same ordering gate; no-update responses produce emptyrequired_state_jsonandtimeline_json; new timeline events correctly populatetimeline_json.
0.9.8¶
Added¶
- test(client-server): add direct route coverage for
joined_membersand presence updates: new unit scenarios coverGET /_matrix/client/v3/rooms/{roomId}/joined_membersfor current-member authorization, joined-profile shaping, and left-member exclusion, plusPUT /_matrix/client/v3/presence/{userId}/statusfor explicit online presence, default-offline behavior, malformed-body rejection, forbidden cross-user updates, and/syncdelivery ofm.presenceevents. - test(database): add direct persistence-helper coverage for filters, profiles, client transaction ids, account-data upserts, sync-stream rows, and room aliases: new unit scenarios exercise
store_filter/find_filterupserts and sensitive JSON handling,store_profilewith targeted displayname/avatar updates and missing-user rejection,store_client_txnidempotency so the first stored response wins while room/type scoping still permits distinct records,store_account_dataglobal-vs-room upsert behavior,record_device_list_changeandupsert_presencestream-id behavior and validation, andstore_room_aliaslookup/duplicate/missing-room handling.
0.9.7¶
Fixed¶
- fix(sync): route
POST /_matrix/client/unstable/org.matrix.simplified_msc3575/syncto the MSC4186 handler (0.9.7): Element X (matrix-rust-sdk) probes for sliding sync via thesimplified_msc3575path, not themsc4186path. Theunstable_featuresflagorg.matrix.simplified_msc3575 = truewas already advertised in 0.9.6, but the endpoint itself returned 404, causing the client to enter a tight retry loop — hundreds of failed sliding-sync attempts per second with concurrent fallbackv3/synccalls. The dispatcher now accepts both/_matrix/client/unstable/org.matrix.msc4186/syncand/_matrix/client/unstable/org.matrix.simplified_msc3575/sync, routing both to the same MSC4186 handler.
0.9.6¶
Fixed¶
- fix(ci): make coverage reporting match the actual project surface: the Codecov/gcovr path now excludes the real process entrypoint (
src/main.cpp) rather than the nonexistentsrc/homeserver/main.cpp, and the coverage workflow now filters headers toinclude/merovingian/instead of every file staged underinclude/. This stops vendored/platform headers from diluting the reported percentage and aligns the uploaded report with the code we actually own. - fix(sync): advertise MSC4186 with the compatibility flag Element X actually probes:
GET /_matrix/client/versionsnow exposes bothunstable_features["org.matrix.msc4186"] = trueandunstable_features["org.matrix.simplified_msc3575"] = true. Element X's upstreammatrix-rust-sdkcurrently autodetects sliding sync via theorg.matrix.simplified_msc3575flag, so advertising onlyorg.matrix.msc4186caused the client to reject the homeserver before ever calling the sliding-sync endpoint.
Added¶
- test(sync): add direct MSC4186 room-list and extension coverage: new unit scenarios exercise
compute_room_listfiltering/sorting/incremental SYNC behavior andbuild_extensionsscoping forto_device,e2ee,account_data,receipts, andtyping. A dedicated tooling test now guards the coverage workflow/config so future changes cannot silently widen the measured surface or exclude the wrong entrypoint again.
0.9.5¶
Fixed¶
- fix(rooms):
GET/POST /_matrix/client/v3/publicRooms?server=<remote>now proxies to the remote homeserver (spec §10.5.1): both endpoints previously ignored theserverquery parameter and returned an empty local room list, causing "no rooms found" when clients searched a remote server (e.g.grapheneos.org). The handlers now check the parameter: when it names a different server the request is forwarded toGET /_matrix/federation/v1/publicRooms(unfiltered) orPOST /_matrix/federation/v1/publicRooms(when afilter.generic_search_termis present). Whenserveris absent or equals the local server name the existing local-list path is used unchanged. Unauthenticated federation (outbound_clientnot configured) surfaces as 502 M_UNKNOWN rather than a silent empty list. The rawsincepagination token is now preserved and forwarded to the remote server instead of being parsed as a local integer offset.
0.9.4¶
Added¶
- feat(sync): MSC4186 Simplified Sliding Sync (unstable) —
POST /_matrix/client/unstable/org.matrix.msc4186/sync: implements the full MSC4186 Simplified Sliding Sync proposal as an unstable extension. The endpoint is advertised viaunstable_features["org.matrix.msc4186"] = truein/_matrix/client/versions. Features: named room lists with windowed ranges, sort criteria (by_recency,by_notification_count,by_name), list operations (SYNC / INVALIDATE / INSERT / DELETE / UPDATE), per-room state and timeline withrequired_statewildcards, explicit room subscriptions with independent parameters, and all five MSC4186 extensions (to_device, e2ee, account_data, receipts, typing). Long-polling reuses the dedicated sync thread pool with the same slice-and-deadline pattern asv3/sync. Per-connection state is keyed byuser_id/device_id/conn_idand tracks previous list windows so subsequent requests return only incremental ops. Position tokens (pos) reuse the existing hex-encodedStreamTokentriplet. Overlapping or inverted ranges are rejected with 400 M_BAD_JSON. - test(sync): MSC4186 sliding sync integration test suite (
tests/integration/test_sliding_sync_flow.cpp): seven integration scenarios exercising the full HTTP handler end-to-end — advertisement in/_matrix/client/versions, initial SYNC op withinitial:true,required_statewildcard filtering,timeline_limitenforcement, incremental sync withoutinitial:trueon already-seen rooms,to_deviceextension delivery, ande2eeextension key counts. Also adds unit tests (tests/unit/test_sliding_sync.cpp) covering the parser layer and MSC4186 conformance tests (tests/conformance/test_sliding_sync_conformance.cpp) for all MUST requirements verifiable at the parser boundary.
0.9.3¶
Fixed¶
- fix(sync): server respects client-requested
/synctimeout in full (spec §9.4): the/synclong-poll handler now waits for the full client-requested duration. Previously a hard 5 s cap in the sync-pool dispatch lambda overrode every client timeout, causing connections with?timeout=30000to fire after 5 s and re-poll needlessly. The sync pool now polls in 5 s slices internally (so shutdown remains bounded) but each slice counts against the client's actual deadline. Clients that omittimeoutcontinue to receive an immediate response per the spec. - fix(log): promote major auth and server lifecycle events from DEBUG to INFO:
login.accepted,start.complete,start.database_ready,start.listeners_ready, andstart.hardening_controlsare now logged at INFO level. Previously every structured diagnostic was emitted at DEBUG, making logs uninformative unless debug mode was explicitly enabled. ERROR and WARNING events (login rejections, audit failures) were already routed correctly; this change covers the success-path events that operators need to confirm normal operation.
0.9.2¶
Fixed¶
- fix(auth): access tokens no longer silently expire for clients that did not opt into refresh tokens (spec §5.6.2):
login_local_usernow accepts awith_ttlflag; the configuredaccess_token_lifetime_msis applied only when the client explicitly requests refresh-token support ("refresh_token": truein the login body). Clients that do not opt in receive a non-expiring access token, conforming to the Matrix spec requirement that servers SHOULD NOT expire access tokens without co-issuing a refresh token. Previously, every login silently set a 1-hour TTL regardless, causing users to be logged out every hour with no warning and no way to recover the session.
0.9.1¶
Fixed¶
- fix(http): wire TLS sync pool to eliminate main-pool starvation and federated E2EE key-share delay:
serve_tls_httpnow passessync_pooland a TLS-aware write callback through toserve_stream. TLS long-poll/syncconnections are offloaded to the dedicated 32-thread sync pool identically to the plain-HTTP path, freeing main pool threads for federation requests. Previously thesync_poolparameter was silently discarded (/*sync_pool*/), causing all TLS long-poll connections to block main pool threads for up to 30 seconds. This starved federation transaction processing, delaying delivery ofm.direct_to_devicekey-share EDUs and producing the observable symptom of taking up to 30 seconds to decrypt a newly-joined federated user's replies.TlsConnectionStreamis updated to holdshared_ptr<TlsConnection>so the TLS state can be shared safely between the read phase (main pool thread) and the async write phase (sync pool thread). The sync wait cap remains 5 seconds, matching the plain-HTTP path.
0.9.0¶
Fixed¶
- fix(auth): include
soft_logout: truein 401 responses for expired access tokens (spec §5.7.2): when an access token is found-but-expired the server now returns{ "errcode" : "M_UNKNOWN_TOKEN", "error" : "unauthenticated", "soft_logout" : true}so Matrix clients use their refresh token rather than performing a full session logout. Revoked tokens continue to return a plain 401 withoutsoft_logout, preserving hard-logout semantics for explicit revocations.
Changed¶
- chore(release): beta milestone — promote from pre-beta (0.8.x) to beta phase (0.9.0): version number advanced to 0.9.0 per the versioning scheme phase markers. No functional changes; this commit updates all version strings across
meson.build, source files, packaging metadata, and build scripts. - docs: update README to reflect beta status: banner note and Project Status section updated from pre-beta/in-development language to beta; pre-beta changelog history archived to
CHANGELOG-pre-beta.md.