Stop a decrypted OAuth client from being quoted back by its own parser - #480
Conversation
`currentClient` parsed the deployment's OAuth client straight out of the vault
with nothing around the parse. `JSON.parse` reports failure by quoting the input
it choked on, and the input there is the DECRYPTED client -- so a row that is
corrupt, wrongly encrypted or half written threw a SyntaxError carrying the
client secret, out into `callTool`'s catch and `refreshTools`' catch, which
write it to the `mcp.call_failed` payload and to `mcp_servers.last_error`. Both
are durable and both are drawn on the Plugins page. Under Bun's parser a stored
value that is a bare token comes back whole:
JSON Parse error: Unexpected identifier "secret_notJsonClient..."
The two sibling readers in the same file already guard this exact parse and
answer null, because unreadable is the same as none. This one refuses instead,
since its callers are mid-call and would have to turn a null into that refusal
on the next line anyway. It raises `unusableClient`, which `secretFor` already
raises for a revoked or missing row, so the operator still learns the credential
is broken -- distinct from `noClient`'s holding none -- without any of the bytes.
The decrypt stays outside the guard, so `secretFor`'s own refusal is not caught
and relabelled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
guidovizoso
left a comment
There was a problem hiding this comment.
Approving — the fix is correct and well-scoped.
What I checked rather than took on trust:
- The decrypt really is hoisted out of the
try, sosecretFor's own revoked/not-found refusal isn't caught and relabelled.JSON.parseis the only statement inside the guard that can throw. currentClient()has exactly one call site (store.ts:933), before the transaction and outside therefuseAndReplaceEvictedClientcatch — so the newPluginRefusedErrorcan't be mistaken for a vendorinvalid_clientand can't trigger a re-registration. (The description says "both its callers"; there's only one. Harmless.)- The sweep holds: three parses of decrypted material in this file (877, 1301, 1436), and the other two already swallow. The
credentials.tserror strings carry no ciphertext or plaintext, so the rethrow path is clean. - The tests aren't vacuous:
listNeedsCredentialis true for MCP sorefreshToolsrefuses insideconnectionTokenForbefore any network call;recordConnectiondoesn't callrefreshTools, soconnect()can't wipe the suite's tool row; the freshkeyIdper call is required by the unique partial index indrizzle/0015_credentials_one_live_key.sql; andauditRowsForis scoped to the suite-suffixed ref with no earliermcp.call_failedrow that could satisfy thetoContain(UNUSABLE)assertion for free. tsc --noEmitandbiome checkclean on this head. I didn't run the integration tests — locally they target the live dev database.
One nice-to-have inline, on the shape of the parsed client. Pre-existing and not a blocker.
…ntax
Guarding the parse answered for SYNTAX and left the `as OAuthClient` cast behind it
answering for nothing, at all three readers. A vault row holding
`{"client_id":"","client_secret":""}` -- snake_case where the type is camelCase, or
empty, as a hand-repair or a half-written row leaves it -- parses cleanly and yields a
client whose `clientId` is `undefined`.
SHAPE AND SYNTAX ARE ONE CONCERN: either way the deployment holds a client it cannot
use, and the operator's signal has to survive both. `unusableClient` already says
exactly that, and is deliberately distinct from `noClient`'s holding none.
The shape half was ending WORSE than the syntax half the guard was written for. The
syntax refusal happens before the transaction and outside the eviction catch, so it
cannot be mistaken for a vendor's `invalid_client`. A misshapen client was not refused
at all: `currentClient` returned it, the exchange inside the transaction sent an
`undefined` client id, and the vendor's `invalid_client` WAS caught -- reaching
`refuseAndReplaceEvictedClient`, which read it as the vendor having disowned our
registration and, past the re-registration backoff, called `registerClient` and
`persistOAuthClient({ by: "deployment" })`. So a corrupt LOCAL row replaced the
deployment-wide client every existing user's consent was granted against, and told the
operator `clientReplaced` -- the vendor forgot us -- rather than naming the credential
that actually broke. Reproduced before it was closed:
offered to vendor: [null]
operator was told: "Notion no longer recognises this deployment's OAuth client,
so this cannot be called. The deployment has registered itself again --
connect Notion again in Settings."
registrations: [{ registrationUrl: ".../register", redirectUri: ".../callback" }]
client now held: { clientId: "dyn-2", clientSecret: "" }
The criterion and its reason live in one place, `isUsableClient`, because all three
readers share both and a future reader changing one would otherwise miss the others.
Each site keeps the answer it already gave: `currentClient` raises, since its contract
is a client or a throw and its callers are mid-call; `heldOAuthClient` and
`storedOAuthClient` answer null under their existing "unreadable is the same as none"
comments, because their caller's response to none is to go and register one.
The id has to be there; the secret only has to be a string. A public client registered
dynamically proves itself with PKCE and is stored with an empty secret ON PURPOSE --
`registerDynamicClient` checks the id this same way and defaults the secret to `""` --
so demanding a non-empty secret would have refused every self-registering entry in the
catalogue, which is most of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
|
Done in Corrupt local row, deployment-wide remedy, and the credential that actually broke never named. No guard, no narrowing, no backoff caught it. One correction to what I was going to do. I had this as "both fields present and non-empty". Non-empty on the secret would have been a regression: The check is one shared predicate, It also closes a quieter one at the null sites: unguarded, Three tests, all red first — the refusal naming the credential instead of carrying it, the client not being replaced, and the consent flow reading it as none. 60 pass on a fresh database, Two things you flagged, both correct:
One pre-existing failure you should know about, unrelated to this change: |
What
currentClient()parsed a decrypted OAuth client with an unguardedJSON.parse. When the stored value is not valid JSON, the parser reports failure by quoting the input it choked on — and that input is the decrypted client secret. The throw escapes intocallTool's catch, which writesmcp.call_failedand setsmcp_servers.last_error.So a client secret could be written into two durable stores, both of which the Plugins page draws for anybody who can read it.
Reproduced before fixing
With a vault row that decrypts cleanly to a non-JSON value — the shape a wrongly-encrypted row really has, a bare secret where a client object belongs:
audit_events.payloadonmcp.call_failed:mcp_servers.last_error:Two details worth recording, because they change the severity:
secret_…,sk_…), so the entire secret is disclosed, not a fixed-width window. V8 would leak a smaller slice. Both leak.Unterminated stringand leaks nothing. The exposure comes from wrongly-encrypted, hand-edited or wrong-key rows rather than partial writes.The fix
Guard only the parse, and never carry the parser's words.
The decrypt is hoisted out of the guard so
secretFor's own refusal for a revoked or missing row is not caught and relabelled. The catch throwsPluginRefusedError(unusableClient)— the messagesecretForis already handed for an unusable client row.This follows the two sibling readers in the same file,
heldOAuthClientandstoredOAuthClient, which both swallow this parse under "unreadable is the same as none". They returnnullbecause their callers are deciding whether a consent flow can start.currentClient()cannot: its contract is aStoredClientor a throw, and its single caller is mid-call and would convert a null into this same refusal one line later. So it raises the fact instead of returning it.The same defect on the vendor-reply path was already fixed at
exchangeRefreshTokenOverHttp, whose test suite describes this exact mechanism. That is the established local precedent this follows.The operator signal survives.
unusableClientis deliberately distinct fromnoClient— "holds a client it cannot use" versus "holds none" — and names connecting again as the remedy. An operator can still tell the credential is broken; nobody learns what was in it.Tests
Two tests asserting the plaintext appears in neither the audit payload nor
last_error. Both verified red against the unfixed code, quoting the secret verbatim in the failure. The guard was then mutated tocatch (e) { throw e }and both failed identically, so they are load-bearing rather than passing by construction.Sweep
store.tshas exactly three parses of decrypted material; the other two were already guarded, and this was the only one that was not. Nothing else in the file puts a decrypted value into a template, a log line or an error text. Checked outside the file too:credentials.ts'sparseEnvelope,plugins/oauth.tsandagents/callback-token.tsall guard and none echo ciphertext. No further instances.Scope
Found during an unrelated review of the Composio transport branch, verified to predate it, and deliberately split out so a security fix is not tangled with a large feature branch.
🤖 Generated with Claude Code
https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x