Point a custom MCP server only at a credential of its own kind - #214
Conversation
Adding a custom server takes a credential id from the request body, and the add is what spends it: refreshTools runs before the method returns, and for a custom server there is no catalogue entry, so connectionTokenFor decrypts whatever the id names and listTools sends it to the URL from the same request. Nothing checked which credential it was. So an administrator could name any row in the vault. Naming one person's mcp_user_token had that person's decrypted token arrive at an administrator-chosen address as a bearer token, during the add, before any grant, policy check or Bot existed. GET /api/admin/credentials lists every row's id, kind, provider and keyId, and for a user token the keyId is the person, so picking a target was one read. Only kind mcp answers "this server's own token". A user token is one person's grant and an OAuth client identifies the deployment to a vendor. Spending either here uses a credential on behalf of somebody who never agreed to it, which is the same objection POST /api/admin/credentials already makes when it refuses to mint those two by hand. A credential that does not exist is refused in the same words as one of the wrong kind, so the endpoint cannot be asked which ids are real. The id is also shape-checked before the lookup, because credentials.id is a uuid column and an unshaped value made the query itself fail, handing back a database error where a refusal belongs. An empty string now reads as no credential rather than breaking the foreign key. Five tests, red before the change. Removing the guard turns three of them red again, and the two that must keep working, a server with its own token and a server with none, pass either way.
Names the disclosure explicitly: a deployment where somebody pointed a custom server at a person's connector token should treat that token as disclosed.
The add is an upsert, so an existing server holding its own token can be re-added naming somebody else's. The guard already runs before the write and the existing pointer survives the refusal, but nothing held that: every other test used a fresh id, so moving the check below the insert would have kept them all green.
dd85312 to
5f1688e
Compare
davidmckayv
left a comment
There was a problem hiding this comment.
Approving on the code, and on watching the tests bite.
Verified the hole rather than trusting the description. For a custom server catalogueEntry is null, so connectionTokenFor falls into the branch that decrypts whatever credential_id names with no kind check, and refreshTools runs inside addCustomServer and sends that plaintext to the URL from the same request. One admin call, any vault row, an attacker-chosen host. That is a confused deputy, and "admin-only" is not much comfort when the thing it hands over is the deployment's own model key or somebody's personal connector token.
The tests earn their place. I neutered the guard and ran the suite: five go red, including the upsert path and the id-oracle case. They are not decorative.
Two things I liked that the description undersells: refusing "wrong kind" and "no such credential" in the same words, so the endpoint cannot be asked which ids are real; and checking the UUID shape before the lookup, so a malformed id is a refusal rather than a database error surfacing as a 500.
One thing for a follow-up, not this PR. addServer — the curated path, one function above — takes credentialId with no kind check either. Not reachable today because the catalogue holds a single user-oauth entry with a fixed host, but the file's own comment invites forks to re-add the vendors that were taken out, and the hole reopens the moment one does. Worth closing while the reasoning is fresh.
Checks: typecheck, lint and format clean; CI green.
Closes #217.
What this changes
addCustomServertakes a credential id from the request body, and the add is what spends it. Thetool refresh runs before the method returns; for a custom server there is no catalogue entry, so
connectionTokenFordecrypts whatever that id names andlistToolssends it to the URL from thesame request. Nothing checked which credential it was.
So an administrator could name any row in the vault. Naming one person's
mcp_user_tokenhad thatperson's decrypted token arrive at an address the administrator chose, as a bearer token, during the
add, before any grant, policy check or Bot existed.
GET /api/admin/credentialslists every row'sid, kind, provider and
keyId, and for a user token thekeyIdis the person, so choosing a targetwas a single read.
Only kind
mcpanswers "this server's own token". A user token is one person's grant and an OAuthclient identifies the deployment to a vendor. Spending either here uses a credential on behalf of
somebody who never agreed to it, which is the objection
POST /api/admin/credentialsalready makeswhen it refuses to mint those two by hand, in a comment saying so in as many words. This is the same
rule at the point they are referenced rather than created.
A credential that does not exist is refused in the same words as one of the wrong kind, so the
endpoint cannot be asked which ids are real. Two smaller things came out of the same guard, both
reachable through the route, which passes the body field through untouched: the id is shape-checked
before the lookup, because
credentials.idis auuidcolumn and an unshaped value made the queryitself fail and handed back a database error where a refusal belongs; and an empty string now reads
as no credential rather than breaking the foreign key.
Deliberately not in this change. The field itself stays. Never accepting a caller-supplied
credentialIdis the stricter option and is arguably the right one, since nothing in the app sendsit (
addCustomServerMutationOptionshas no callers, and the admin screen always mints a token andpoints at the one it just made), but that is your call rather than mine and it is a smaller diff
than this one. Say the word and I will cut it to that.
A narrower gap this leaves open. The check is on kind, not on which server the credential
belongs to, so server A can still be pointed at server B's deployment token. I confirmed that by
running it. Both are deployment-owned rather than a person's, so it is not the cross-person case
above, but it is still a secret going to an address the caller picks.
storeMcpTokenwritesprovider: serverId, so pinning onproviderwould close it; I left it out because it could breaka deployment that reuses one token across servers.
Where it runs
selectoncredentialsinside the existingcall path, and no new writes.
depends on nothing held in the process.
the same id still resolves the way it did before: last writer wins on a row that has already
passed the check. Nothing new races.
request, already rendered as a 400 by the route's existing
CustomServerRefusedErrorbranch.Boundary and audit
this sits in front of a configuration write.
configuration.changedrow is written for a server that was never added. That is the point:the row this used to write is the one naming the credential it was about to spend.
Changelog
Unreleased, naming the disclosure: a deployment where somebody pointed a customserver at a person's connector token should treat that token as disclosed.
Proof
Against a migrated Postgres, driving the real
addCustomServerwith a real encrypted victimcredential and an HTTPS listener standing in for the attacker's collector.
Before:
After:
Eight tests. Six of them are red before the change; the other two are the must-keep-working cases, a
server pointed at its own token and a server with no credential at all, which pass either way.
Removing the guard turns five red again.
One of those tests exists because of a branch the other seven missed. The add is an upsert, so an
existing server already holding its own token can be re-added naming somebody else's, and every
other test used a fresh id, so nothing reached that path. It turns out the behaviour there was
already correct and the existing pointer survives the refusal, but nothing proved it.
Corrected after first posting: this paragraph originally claimed that moving the check below the
insert "would have kept them all green," which is false. The first test asserts
expect(rows).toHaveLength(0), so an insert running before the refusal would turn it red on its own.The branch was genuinely uncovered and the test is worth having; the justification I gave for it was
written from memory rather than from a run, and overstated.
Also exercised by hand and not kept as tests: uppercase and whitespace-padded ids are accepted and
normalise; a revoked credential of the right kind is still accepted and fails later at decryption,
which is pre-existing and leaks nothing, since
decryptCredentialForUserefuses revoked rows.829 non-integration and 195 integration tests pass. Typecheck clean on all four workspaces, lint back
to its pre-existing single info, format clean.
Note for whoever merges: #76 touches
plugins/store.tsimmediately afteraddCustomServer, wherethis change sits, and the same test file in different places. Whichever lands second wants a rebase.