Stop a decrypted OAuth client from being quoted back by its own parser - #480
Open
mxmzb wants to merge 1 commit into
Open
Stop a decrypted OAuth client from being quoted back by its own parser#480mxmzb wants to merge 1 commit into
mxmzb wants to merge 1 commit into
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
mxmzb
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 10, 2026 02:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 both its callers are 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