Skip to content

fix(deploy): keep Synapse's admin API off the internet - #160

Merged
argiad merged 1 commit into
developmentfrom
fix/block-public-synapse-admin
Nov 8, 2026
Merged

argiad merged 1 commit into
developmentfrom
fix/block-public-synapse-admin

Conversation

@argiad

@argiad argiad commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The matrix-synapse catch-all sent every path on matrix.<domain> that MatrixMedia does not serve to Synapse, so /_synapse/admin/* was reachable from the internet. Synapse's reverse-proxy guide says to proxy /_matrix and /_synapse/client and not to expose /_synapse/admin. The admin endpoints still needed an admin token or the registration shared secret, so this is defence in depth, not an open hole. The one exception was /_synapse/admin/v1/server_version, which answers without a token. (The version is also public through /_matrix/federation/v1/version, so that leak is not new.)

Found while fixing the leftover registration sessions in #159.

Changes

  • Traefik (compose stack): the catch-all now also has !PathPrefix(/_synapse/admin), in both routers that carry it:

    • the file router in deploy/templates/traefik-dynamic.tmpl.yaml;
    • the matrix-synapse docker label on the synapse service in deploy/docker-compose.tmpl.yml.

    Traefik runs both providers, so matrix-synapse@file and matrix-synapse@docker share one priority pool. Excluding the path in only one of them changes nothing; the TDD run below shows this. With no router matching, Traefik answers 404.

  • Helm: deploy/helm/synapse/templates/ingress.yaml routed /. An Ingress cannot say "everything except this prefix". A deny path pointed at a dummy backend behaves differently per controller (Traefik skips a path whose backend is missing, so the request would fall through to /). The ingress therefore now allow-lists /_matrix, /_synapse/client and /.well-known/matrix. /.well-known/matrix stays because the dev overlay's serverName is the ingress host. ArgoCD syncs from main, so no cluster changes until a release.

  • Self-check: self_smoke (install.sh, mmctl check, mmctl upgrade) now runs probe_gated on /_synapse/admin/v1/server_version, next to the mm-switch gates. It fails on a 200.

  • CHANGELOG [Unreleased] → Security entry.

What I checked for legitimate public use of /_synapse/admin

Caller How it reaches Synapse Affected?
mm-core (Operator Console admin role, membership gates, moderation, user list proxy, signup register, bot invite login) cfg.matrix.homeserver_url, set as MM_MATRIX_HOMESERVER_URL: http://synapse:8008 in compose and homeserverUrl: "http://synapse:8008" in the Helm mm-core values No
Dashboard / Operator Console SPA (web/) Never calls /_synapse/*; it goes through mm-core's /_mm/admin/.../synapse/users proxy ("the browser never sees Synapse credentials") No
Installer / mmctl (deploy/) Public host only for /_matrix/client/v3/{login,logout,account/whoami}; registration goes through docker exec into matrixmedia-synapse-1 at http://localhost:8008 (#159) No
Mobile apps and FluffyChat (Production/ios, android, fluffychat) No /_synapse/admin references. The vendored matrix-js-sdk in Production/web has isSynapseAdministrator/whoisSynapseUser/deactivateSynapseUser, but nothing calls them No
synapse-admin UI None in compose, Helm or docs —
Docs Only docs/user-management.md, which uses HS="http://localhost:8008" (local dev); no guide tells operators to curl the public host No

To call the admin API by hand, do it on the server: docker exec -it matrixmedia-synapse-1 sh, then curl http://localhost:8008/_synapse/admin/… (CHANGELOG says so).

Verification

  • TDD: the new tests failed first for the right reasons. With only the file router fixed, the routing test still failed with /_synapse/admin -> matrix-synapse matrix-synapse@docker, which proves it guards both providers. A planted - path: /_synapse/admin in the Helm template makes the Helm guard fail.

  • New tests in deploy/test/traefik.bats:

    • A routing evaluator weighs every public router, file and docker label, by Traefik priority. It asserts /_synapse/admin* reaches no router, and that /_matrix/client, federation, media, key, /_synapse/client/* and /.well-known/matrix/client still reach Synapse. Rule syntax it does not know fails the test instead of guessing.
    • The file rule and the label rule must stay identical.
    • Helm, static (always runs) and helm template (skips without helm).
    • The self_smoke wiring.
  • bats deploy/test/*.bats under macOS bash 3.2: 353/353 ok (one existing skip: chown needs root). shellcheck run as in deploy-lint.yml: clean.

  • The CI render-validate steps run locally: all templates render clean, docker compose config -q OK.

  • The helm-validate steps run locally: dependency build, lint, dev and prod overlays all OK. The dev Synapse ingress renders ['/_matrix', '/_synapse/client', '/.well-known/matrix'].

  • Real Traefik v3.5.6 + Synapse v1.163.0 (throwaway local containers, removed afterwards):

    Path development this branch
    /_synapse/admin/v1/server_version 200 {"server_version":"1.163.0"} 404 (Traefik)
    /_synapse/admin/v2/users 401 (Synapse, reachable) 404 (Traefik)
    //, /./, /../ and %61dmin variants — 404 (Traefik cleans and percent-decodes the path before matching)
    /_synapse%2Fadmin/…, /_SYNAPSE/admin/…, /_synapse/Admin/… — forwarded, but Synapse answers 404: no admin servlet runs
    /_matrix/client/versions, /_matrix/federation/v1/version, /_synapse/client/… Synapse Synapse (unchanged)

    probe_gated flags the old config as PUBLIC and passes the new one.

  • Existing installs: render_one rewrites traefik-dynamic.yaml in place, and Traefik v3.5 watches the file itself (add watcher on: /etc/traefik/dynamic.yaml), not only its directory. An install.sh re-run therefore applies the file change without a Traefik restart; I confirmed this live. The label change recreates synapse through compose up -d (Synapse restarts once). Plain mmctl upgrade does not re-render templates, so this ships with the next release's installer (mmctl upgrade --to).

Not touched (code-only PR): the dev/prod servers.

🤖 Generated with Claude Code

argiad added a commit that referenced this pull request Nov 8, 2026
The all-in-one try-it image's nginx sent every path it does not route
elsewhere to Synapse, so /_synapse/admin/* was reachable on the published
port 8008. /_synapse/admin/v1/server_version answered without a token.

nginx now returns 404 for /_synapse/admin, as Synapse's reverse-proxy
guidance asks and as PR #160 does for the Traefik stack. Nothing in the
image needs it through nginx: mm-core's homeserver URL and the init's
SYNAPSE_URL are both http://127.0.0.1:8009.

The image smoke test now asserts /_matrix/client/versions is 200 and
/_synapse/admin/v1/server_version (plain and percent-encoded) is 404
through :8008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@argiad
argiad force-pushed the fix/block-public-synapse-admin branch from afb19b0 to c37259c Compare November 8, 2026 22:09
The Synapse catch-all router sent every path on matrix.<domain> that
MatrixMedia does not serve to Synapse, /_synapse/admin included. Synapse's
reverse-proxy guide says not to expose it; /_synapse/admin/v1/server_version
even answers without a token.

- Traefik: the catch-all excludes /_synapse/admin in BOTH routers that
  carry it, the file router and the docker label on the synapse service.
  Traefik runs both providers, so excluding it in one alone changes nothing.
  No router matches, so Traefik answers 404. /_matrix and /_synapse/client
  are routed as before.
- Helm: an Ingress cannot exclude a prefix, so the Synapse ingress now
  allow-lists /_matrix, /_synapse/client and /.well-known/matrix instead
  of routing /.
- self_smoke fails when /_synapse/admin/v1/server_version answers publicly.

mm-core reaches the admin API at MM_MATRIX_HOMESERVER_URL
(http://synapse:8008, compose and Helm) and the installer through docker
exec, so neither goes through Traefik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
argiad added a commit that referenced this pull request Nov 8, 2026
The all-in-one try-it image's nginx sent every path it does not route
elsewhere to Synapse, so /_synapse/admin/* was reachable on the published
port 8008. /_synapse/admin/v1/server_version answered without a token.

nginx now returns 404 for /_synapse/admin, as Synapse's reverse-proxy
guidance asks and as PR #160 does for the Traefik stack. Nothing in the
image needs it through nginx: mm-core's homeserver URL and the init's
SYNAPSE_URL are both http://127.0.0.1:8009.

The image smoke test now asserts /_matrix/client/versions is 200 and
/_synapse/admin/v1/server_version (plain and percent-encoded) is 404
through :8008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@argiad
argiad force-pushed the fix/block-public-synapse-admin branch from c37259c to 7584e9e Compare November 8, 2026 22:13
@argiad
argiad enabled auto-merge (rebase) November 8, 2026 22:54
@argiad argiad mentioned this pull request Nov 8, 2026
@argiad
argiad merged commit 0100920 into development Nov 8, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant