Skip to content

Synapse v2.1.8 — "revoke means revoke"

Patch release closing two defects in the MCP OAuth authorization server shipped in 2.1.6, plus one dependency advisory. No schema migrations, no new endpoints.

Unlike 2.1.2–2.1.7, none of this came from a live failure: these were found by reading the freshly-landed 2.1.6/2.1.7 code during weekly maintenance. Nothing here is known to have been exploited, and the deployment model (single operator, personal vault) keeps the practical exposure narrow — but the first item makes a security control silently not work, which is worth a release on its own.

Security

  • Changing the static MCP token did not revoke OAuth-issued access, and there was no revocation path at all. Every row in mcp_oauth_tokens gets created by typing the static MCP token into the /authorize consent form — that token is the only credential in the entire OAuth flow (runtime_state.verify_static_mcp_token, deliberately excluding OAuth tokens so there is no delegation chain). But PUT /mcp/auth — set, rotate, or clear — only rewrote vault_state.mcp_access_token_hash and refreshed mcp_auth_cache. It never touched mcp_oauth_tokens or McpOAuthTokenCache, and neither did anything else: the cache was only ever written by load() at startup, add() on mint, and remove() on single-row refresh rotation.

The consequence is that rotating a leaked token revoked nothing for the one client class that authenticates via OAuth (claude.ai's web Custom connector). The previously issued access token kept satisfying exactly the same /mcp/server gate as the static bearer, because BearerAuthMiddleware treats an OAuth match identically. Worse, its refresh_token stays valid for 90 days and — by design — never re-presents the static token, so a grant_type=refresh_token call could keep minting fresh hour-long access tokens for the rest of that window. The only genuine revocation available was turning remote_mcp_enabled off, which also disconnects Claude Desktop and every other MCP client.

This was an omission rather than a deliberate design decision: revoked_at was already modelled and documented for precisely this case — "set when rotated-away-from (refresh) or explicitly revoked" (app/models.py, McpOAuthToken) — the "explicitly revoked" half simply had no caller.

Fixed by cascading the credential change onto the grants. When PUT /mcp/auth results in an actually-different mcp_access_token_hash, it bulk soft-revokes every non-revoked row in the same transaction and clears McpOAuthTokenCache. Every existing read path (_load_mcp_oauth_token_cache, _rotate_tokens) already filters on revoked_at IS NULL, so no other code changed. The comparison is against the hash captured before any mutation, which keeps the blast radius exact: a PUT that only flips allow_without_token leaves the credential untouched and revokes nothing.

  • redirect_uri validation was a string prefix match, so localhost lookalikes passed. _valid_redirect_uri accepted any URI matching uri.startswith("http://localhost") — which includes http://localhost.attacker.example/cb, a host that merely begins with the word "localhost" and is entirely attacker-controlled. Combined with just-in-time client registration (unavoidable, and correct, for claude.ai's connector), a crafted /authorize link could have carried the issued authorization code off-box in plaintext once the operator approved the grant, and the attacker — who chose the code_challenge — could then have exchanged it for a working access token.

Exploiting it required phishing the operator into approving a grant they did not initiate, so this is a hardening fix rather than a break-glass one. Fixed by parsing the URI and comparing urlsplit().hostname against an exact loopback allow-set instead of prefix matching. The same change rejects degenerate inputs such as a bare "https://".

Changed

  • http://127.0.0.1 and http://[::1] redirect URIs are now accepted (deliberate, documented widening). The old prefix check recognised only the literal string "localhost", so pointing a development client at the loopback address was rejected for no defensible reason — plaintext http is permitted here precisely because the traffic never leaves the machine, which is equally true of all three spellings.

  • dompurify 3.4.11 → 3.4.13 (GHSA-c2j3-45gr-mqc4: CUSTOM_ELEMENT_HANDLING bypasses afterSanitizeElements for allowed custom elements). Patch-level bump inside the existing ^3.4 range, no API change. Not believed exploitable as Synapse configures it — renderMarkdown.ts sanitizes against an explicit ALLOWED_TAGS allowlist containing no custom elements, and never sets CUSTOM_ELEMENT_HANDLING — but this is the one library standing between model-generated / source markdown and three dangerouslySetInnerHTML call sites, so it gets the benefit of the doubt. npm audit --omit=dev now reports zero vulnerabilities.

Upgrade notes

No schema migrations. docs/er/schema.mmd is unchanged.

One behaviour change worth knowing about before you upgrade: from 2.1.8, setting, rotating, or clearing the MCP token in Settings will disconnect any claude.ai Custom connector that authenticated over OAuth. That is the intended behaviour — it is what those buttons were always supposed to do — but if you rotate the token as routine hygiene, expect to re-authorize the connector afterwards (open the connector, click Connect, approve with the new token). Claude Desktop and other clients using the static bearer token directly are unaffected beyond the usual need to update their configured token.

Nothing needs to be done for the redirect_uri change unless you had a client registered against a non-loopback http:// URI, which the old check would only have accepted if its host started with the literal string localhost.