Skip to content

fix(mcp): bind /connect/mcp consent to the local bridge (#368)#370

Open
ducnmm wants to merge 1 commit into
devfrom
fix/connect-mcp-consent-phishing
Open

fix(mcp): bind /connect/mcp consent to the local bridge (#368)#370
ducnmm wants to merge 1 commit into
devfrom
fix/connect-mcp-consent-phishing

Conversation

@ducnmm

@ducnmm ducnmm commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the consent-phishing surface reported in #368. The /connect/mcp page registered a delegate key on the connected wallet's Walrus Memory account based entirely on query-string parameters, with no proof the request came from the local MCP bridge it claims to serve. An attacker could send a victim a link on the trusted memory.walrus.xyz domain — "Cursor wants access to your Walrus Memory" — and, on approval, permanently grant the attacker's own delegate key read / decrypt / write access to all the victim's memories until manually revoked.

This is an app-level consent-phishing issue, not a wallet- or contract-level one: the branded first-party consent screen lent credibility to a fully attacker-defined request. The Move contract is unchanged — the owner check already passes for a victim-signed tx, and (per the report) the public_key / sui_address desync is not what enables the attack.

What changed

1. Bind the on-chain write to the local bridge (core fix). Reuses the existing single-use connectState token, lifting it from guarding only the callback to also gating the signature:

  • packages/mcp/src/login.ts: add GET /handshake?state= that returns { ok: true } only when the state matches the token the bridge minted (constant-time compare, behind the same Host / Origin / PNA guards as the callback). Allow GET in CORS.
  • apps/app/src/pages/ConnectMcp.tsx: probe the bridge on load and re-verify immediately before signing; refuse to sign add_delegate_key unless it confirms. A phishing link reaches no local bridge — or one whose token differs — and fails the handshake, so the page never signs.

2. Make the consent screen honest (report recommendations 2–4).

  • Show the full delegate address and public key (were truncated / hidden).
  • Render the client label as untrusted free text, not a verified "<label> wants access" identity.
  • State plainly that approving grants persistent read + decrypt + write to all memories across all namespaces until revoked; warn on a non-default relayer.
  • Don't auto-skip the consent screen when a wallet is already connected.

Testing

  • packages/mcp — new test/login-handshake.test.mjs drives the real localhost listener: correct token → 200 {ok:true}, wrong / missing token → 403 {ok:false}, and a valid callback still resolves credentials. Full suite: 9/9 pass.
  • apps/apptsc -b clean, eslint clean.

Deploy ordering (fail-closed — please read)

The app now rejects an older bridge that lacks /handshake. This is intentional and matches the existing "force a bridge upgrade" behaviour of the connectState param. Publish the updated @mysten-incubation/memwal-mcp bridge before deploying the app so legitimate users aren't blocked. The error screen guides users to update the client (npx @mysten-incubation/memwal-mcp@latest login).

Fixes #368.

The /connect/mcp consent page signed add_delegate_key purely from
query-string params, with no proof the request came from the local MCP
bridge it claims to serve. An attacker could send a victim a link on the
trusted memory.walrus.xyz domain and, on approval, register the attacker's
own delegate key with read/decrypt/write access to all the victim's
memories until manually revoked.

Bind the on-chain write to the local bridge, reusing the existing
single-use state token:
- bridge: add `GET /handshake?state=` that returns { ok: true } only when
  the state matches the token it minted (constant-time); allow GET in CORS.
- app: verify the bridge on load and again right before signing; refuse to
  sign unless it confirms. A phishing link reaches no local bridge (or one
  whose token differs) and fails the handshake.

Make the consent screen honest (report recommendations 2-4):
- show the full delegate address and public key (were truncated / hidden);
- render the client label as untrusted free text, not a verified identity;
- state plainly that approving grants persistent read + decrypt + write to
  all memories across all namespaces until revoked; warn on a non-default
  relayer;
- don't auto-skip the consent screen when a wallet is already connected.

Fail-closed: an older bridge without /handshake is rejected, matching the
existing "force a bridge upgrade" behaviour of the connectState param, so
publish the updated bridge before deploying the app. The Move contract is
unchanged — the owner check already passes for a victim-signed tx, and the
public_key/sui_address desync is not what enables this attack.

Adds packages/mcp/test/login-handshake.test.mjs.
@harrymove-ctrl harrymove-ctrl self-requested a review July 9, 2026 05:13
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.

[Security / Medium] /connect/mcp consent screen enables delegate-access phishing (attacker-controlled label + delegate address, no source binding)

2 participants