Skip to content

Stop a decrypted OAuth client from being quoted back by its own parser - #480

Open
mxmzb wants to merge 1 commit into
mainfrom
fix/oauth-client-parse-leak
Open

Stop a decrypted OAuth client from being quoted back by its own parser#480
mxmzb wants to merge 1 commit into
mainfrom
fix/oauth-client-parse-leak

Conversation

@mxmzb

@mxmzb mxmzb commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What

currentClient() parsed a decrypted OAuth client with an unguarded JSON.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 into callTool's catch, which writes mcp.call_failed and sets mcp_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.payload on mcp.call_failed:

"failure":"JSON Parse error: Unexpected identifier \"secret_notJsonClient…\""

mcp_servers.last_error:

JSON Parse error: Unexpected identifier "secret_notJsonClient…"

Two details worth recording, because they change the severity:

  • Bun runs JavaScriptCore, not V8. JSC quotes the whole offending token. Real client secrets are identifier-shaped (secret_…, sk_…), so the entire secret is disclosed, not a fixed-width window. V8 would leak a smaller slice. Both leak.
  • Truncated-but-valid-prefix JSON yields only Unterminated string and 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 throws PluginRefusedError(unusableClient) — the message secretFor is already handed for an unusable client row.

This follows the two sibling readers in the same file, heldOAuthClient and storedOAuthClient, which both swallow this parse under "unreadable is the same as none". They return null because their callers are deciding whether a consent flow can start. currentClient() cannot: its contract is a StoredClient or 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. unusableClient is deliberately distinct from noClient — "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 to catch (e) { throw e } and both failed identically, so they are load-bearing rather than passing by construction.

Sweep

store.ts has 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's parseEnvelope, plugins/oauth.ts and agents/callback-token.ts all 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

`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
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