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_tokensgets created by typing the static MCP token into the/authorizeconsent 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). ButPUT /mcp/auth— set, rotate, or clear — only rewrotevault_state.mcp_access_token_hashand refreshedmcp_auth_cache. It never touchedmcp_oauth_tokensorMcpOAuthTokenCache, and neither did anything else: the cache was only ever written byload()at startup,add()on mint, andremove()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_urivalidation was a string prefix match, solocalhostlookalikes passed._valid_redirect_uriaccepted any URI matchinguri.startswith("http://localhost")— which includeshttp://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/authorizelink could have carried the issued authorization code off-box in plaintext once the operator approved the grant, and the attacker — who chose thecode_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.1andhttp://[::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 — plaintexthttpis permitted here precisely because the traffic never leaves the machine, which is equally true of all three spellings. -
dompurify3.4.11 → 3.4.13 (GHSA-c2j3-45gr-mqc4:CUSTOM_ELEMENT_HANDLINGbypassesafterSanitizeElementsfor allowed custom elements). Patch-level bump inside the existing^3.4range, no API change. Not believed exploitable as Synapse configures it —renderMarkdown.tssanitizes against an explicitALLOWED_TAGSallowlist containing no custom elements, and never setsCUSTOM_ELEMENT_HANDLING— but this is the one library standing between model-generated / source markdown and threedangerouslySetInnerHTMLcall sites, so it gets the benefit of the doubt.npm audit --omit=devnow 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.