The whole point of fetchproxy is to let a Node process speak as the user's signed-in browser. That's a powerful primitive. This doc enumerates what we defend against, what we don't defend against, and how the defenses are structured.
If you don't trust everything else running on your local user account, fetchproxy is not for you. Read §Local trust boundary first.
This document tracks 3.0.0 (protocol 4). Two structural changes vs. 0.0.x / 0.1.x set the threat model up, and a third — 3.0.0 — changed what the first of them is worth:
- Concentrator + end-to-end encryption. One WS port serves N MCPs. One MCP wins the port and routes encrypted frames between the others and the extension. The host MCP cannot read peer traffic — every data frame is AES-256-GCM encrypted under a per-session key each MCP derives with the extension. This adds T-host-MITM as an explicit threat and closes it. Up to and including protocol 3 the MCP's half of that derivation was its long-term identity key, which is the sentence 3.0.0 had to retract; see the bullet below and §T-host-MITM.
- Capabilities. The MCP-facing API is gated by an explicit, user-approved capability set declared in the hello frame.
fetchis the default;read_cookiesis strictly opt-in and surfaces a visible warning at pair time. The capability set is part of the trust record; widening or narrowing it forces a re-pair. - Forward secrecy and per-frame AAD (3.0.0, protocol 4). Both ends now mint a per-session X25519 ephemeral, each covered by the other's signature, and the session key is derived ephemeral × ephemeral salted with the handshake transcript — so an MCP's identity key no longer decrypts a recording of that MCP's own traffic, and
mcpId ‖ seq ‖ directionis authenticated as GCM additional data rather than riding outside the envelope. What that retracts is in §T-host-MITM and §T-remote-bridge; what it does not buy is in §What protocol 4 does not fix, stated there rather than left to be discovered.
| What | Defense |
|---|---|
| Random local process trying to use your sessions | Per-identity SAS pair prompt in the extension popup. New identities never auto-trust. |
| Compromised MCP fetching off-domain | Per-MCP domain allowlist. Each MCP declares domains: [...]; the extension rejects any fetch outside the set. |
| Host MCP reading peer MCP traffic on the shared bridge | End-to-end AES-256-GCM between each MCP and the extension. Host routes by mcpId but never holds the session key — and since 2.0.0 the ready signature covers the ephemeral key, so it cannot substitute one it does. |
| A party who obtains an MCP's identity key afterwards, holding a recording of its frames | Nothing, up to protocol 3: the identity was the MCP's half of the key exchange, so a recording plus the identity was the plaintext, passively and retroactively. Since 3.0.0 the key is ephemeral × ephemeral and the identity only authenticates. See §T-host-MITM. |
| A party in the frame path replaying, re-filing or reflecting a recorded frame | Monotonic seq per direction, and since 3.0.0 mcpId ‖ seq ‖ direction is authenticated as AAD, so a bumped counter, another MCP's id or a reflected frame fails the GCM tag rather than a check downstream. See §T9. |
| A webpage you visit connecting to the WS | WS binds 127.0.0.1; the upgrade handler rejects non-extension origins; Chrome Private Network Access blocks public-origin preflights. |
| MCP silently expanding its powers post-pair | The trust record stores the approved domains AND capabilities set. A changed server name, domain set or identity key → re-pair prompt; wider capabilities or keys are served only as approved ∩ declared until the person accepts a scope-update offer. |
| Arbitrary JS execution in your tabs | The protocol has no eval_js, inject_script, or equivalent. graphql is NOT this — it can only invoke a page-declared GraphQL operation through the page's own Apollo client, never arbitrary page code. See §T-graphql-misuse. |
| Storage exfiltration | read_storage, read_indexeddb, etc. don't exist. read_cookies is a deliberate, narrow opt-in. |
| An MCP tampering with your session | The one write verb, write_cookies, can only overwrite cookies the MCP already declares readable, only on declared domains, only when the cookie already exists, and only under its own approved capability. See §T-cookie-write. |
| Something else answering as your browser | The MCP pins the extension's identity on first pair and refuses a different one — the mirror of trustedMcps. See §T-fake-extension. 1.12.0+. |
| A remote bridge the user configured turning into a way in | The target is wss:// (or loopback), the credential is a separate field, no configuration removes or repoints the loopback link, and every MCP the relay carries still pairs, pins and encrypts exactly as it does on a laptop. See §T-remote-bridge. 2.1.0+. |
| A hosted relay vouching for an MCP (account attestation) | The vouch binds the hello's identity keys, the link and one hello, so it is useless without that identity's private key, and it is honoured only on the remote link of an account the person approved in the browser. Loopback never honours it. See §T-account-attest. |
| A hosted relay telling a browser it serves, or is standby (room frames) | Display only. bridge-role grants the extension nothing and changes nothing it enforces; a lying relay could already route anywhere it likes. Every room frame is gated on the extension's accepts and never crosses loopback. See §T-room-frames. |
| Multi-user machine sniffing | Out of scope. Localhost binding only. |
fetchproxy is a local trust product. Anything running on 127.0.0.1 under your user account is, by default, in the same trust zone as fetchproxy itself.
This matters because the cookie jar is not freely readable by every local process — Chrome and Safari encrypt cookies under per-user Keychain keys on macOS, and accessing them either prompts the user (Keychain dialog) or requires the right entitlements. fetchproxy expands the local attack surface by giving any local process that gets past the trust prompt the ability to act as the user's signed-in browser on the declared domain without a Keychain prompt.
We don't pretend otherwise. The defenses below are about making sure malicious local processes can't quietly trust themselves.
2.2.0+. The concentrator binds 127.0.0.1, and that address is doing work in this document: §T2 defence 1 and the §T-fake-extension argument both rest on "the only thing that can reach the socket is a process on this host". FetchproxyServerOpts.host has always allowed a different address, and FETCHPROXY_WS_HOST now supplies it from the environment the way FETCHPROXY_WS_PORT supplies the port — for the consumer that never sees the option, @fetchproxy/bootstrap. It exists for exactly one topology and is worth being exact about it.
The topology. A hosted deployment that sandboxes each MCP child (chrischall/mcp-host, gVisor) gives every child its own network namespace. Inside one, 127.0.0.1 is a loopback nobody outside the sandbox can reach — the runner's relay agent, which is what dials a hosted child in place of the browser, gets connection refused. So the child binds the sandbox end of a veth pair instead, 10.200.<slot>.2, and the runner dials that. The address is private to a /30 whose other end is the runner: the population that can reach the socket is still "a process on this host", the trust scope 127.0.0.1 states is unchanged, and nothing in extension-trust.ts needs to reason differently. The fence moved; what is inside it did not.
It is not a knob for a laptop. Binding an address the network can route — 0.0.0.0, the machine's LAN address — puts the bridge, and through it the user's signed-in cookies, on the network, and every defence above that says "local" stops being true. Nothing in fetchproxy prevents that bind, for the same reason nothing prevents opts.host; the variable is an operator's statement about where the sandbox is, and the operator is accountable for it being one.
What it refuses. Only a literal IP address (v4 or v6) is honoured. A hostname — localhost included — an empty value or whitespace is ignored and the default applies, never resolved: a resolved name binds whatever the resolver said, which can differ between the child and the thing dialling it, and a mistyped variable must fall through to the bind that has always worked rather than becoming a dead bridge or a bind on something the operator did not write down. An explicit opts.host is a decision made in code and beats the environment. bridgeHealth().host reports the address actually bound so a hosted healthcheck can tell "bound where the relay dials" from "bound on a loopback nothing outside the sandbox can reach".
Binding loopback answers which machine may reach the concentrator. It says nothing about what on that machine, and a WebSocket is the one local socket a web page can open: browsers still allow a page to connect to ws://127.0.0.1. §T2 defence 2 is the layer that keeps pages out, and it did not.
What it admitted. The gate refused any http(s):// origin except localhost and 127.0.0.1, and applied only when an Origin header was present at all — so the opaque null origin went through as well, as did every non-http scheme, because the gate ended in a fallthrough allow that read anything unrecognised as "the extension". That left two populations inside a defence written to hold one thing (the extension, which sends chrome-extension://…) and one thing that sends nothing (a Node peer dialing the concentrator, which has no Origin):
- any page served from localhost — a dev server, a notebook kernel, a local app's UI, a docs preview. Not "dev tools and curl", which is what the comment beside the regex used to claim: curl sends no
Originand is unaffected by refusing one. - any page with an opaque origin — a sandboxed iframe, an
srcdocdocument, adata:URL, afile://page. An opaque origin is what a page gets when it is least trusted, and it was being read as "the extension". - any page on a scheme that is not http(s) — a Tauri, Capacitor, Electron or Ionic app's own UI. Same capability, arrived at through the fallthrough rather than through a rule. The gate is now an allowlist of the three extension schemes plus the absent header, and its last branch refuses.
What such a page could then do is everything an unidentified socket can: send a self-signed peer hello and raise a pair prompt in the user's popup under a name of its choosing, or send an extension hello where no pin exists. It cannot read anything without the user approving a pair — but making the prompt appear at all is the step this layer exists to deny. (Whether Chrome's Private Network Access — defence 4 below — blocks a WebSocket from an opaque origin to loopback is the browser's behaviour and unverified here; a defence of ours may not rest on it.)
Both are now refused with 403, and the refusal is not silent: it names the origin on stderr and says what to set, because the population it will actually inconvenience is somebody building against the bridge from a local page.
The escape. FETCHPROXY_ALLOW_LOCAL_ORIGINS=1 puts the two LOCAL populations back — the opaque null origin, and an http(s):// page on localhost, 127.0.0.1 or [::1] — and only those. A public origin stays refused with the variable set, and so does a non-extension scheme: this is an escape for local origins, never a switch that turns the gate off or widens what counts as the extension, which is the shape that ends up set in a deployment. Exactly 1 is honoured; any other value is ignored with a warning rather than read as "on", and a process running with it on says so once at startup. An environment variable rather than an option for the reason FETCHPROXY_IDENTITY_DIR is one: the packages that construct a FetchproxyServer are not edited to debug one of them.
A piece of malware running under your user account opens ws://127.0.0.1:37149, sends a forged hello frame claiming to be opentable-mcp, and tries to use fetch to scrape your reservations / favorites / email-on-file.
Defense — identity-keyed SAS pair prompt. Every MCP holds a long-term Ed25519 + X25519 keypair at ~/.fetchproxy/identity/<server-name>.json (mode 0600). The hello frame carries the public keys, a fresh sessionNonce, this session's X25519 ephemeral public key, and an Ed25519 signature over mcpId || sessionNonce || sessionPub || answersExtNonce (helloSignaturePayload in @fetchproxy/protocol; up to protocol 3 the signature covered mcpId || sessionNonce and there was no ephemeral to cover). The extension verifies the signature, then looks up the identity hash in its trust store (trustedMcps, in the extension origin's IndexedDB — see Defense 4) — and, since 3.0.0, requires the stored identityEd25519Pub to be the key that just signed, which under protocol 3 it did not have to. §T8 says why that half was harmless then and is impersonation now.
If unknown, the extension does NOT respond with a ready frame. Instead it shows a popup:
A new MCP server is asking to relay HTTP requests through your browser. Server:
opentable-mcp v0.10.0Domains:opentable.comCapabilities: HTTP fetches Pair code:1234-5678[Approve] [Cancel]
The 8-digit pair code (SAS — Short Authentication String) is the first 8 bytes of SHA256('fetchproxy/4/pair' || NUL || mcpIdentityX25519Pub || extIdentityX25519Pub || mcpHelloNonce || extHelloNonce || mcpSessionPub), read big-endian and reduced mod 100_000_000, formatted as XXXX-XXXX. The MCP prints it to stderr; the extension shows the same value. The user compares them and clicks Approve. Approving stores the identity-key hash plus the declared domains and capabilities set in the extension's trust store (IndexedDB, closed to content scripts — Defense 4); subsequent connections with the same hash skip the prompt.
3.0.0 (protocol 4) changed both the inputs and the width, and the number is no longer stable: it commits to a TRANSCRIPT, so it differs on every pairing attempt where the v3 code — SHA256(mcpPub || extPub)[0..3] mod 1_000_000, formatted XXX-XXX — was the same six digits for the life of the two identities. Both v3 inputs were public and long-term, so ONE offline grind of ~10⁶ keygens produced a code that stayed usable against that MCP identity forever. What v4 buys, exactly: the grind becomes ONLINE and per-pairing (a fresh nonce from each side and the MCP's ephemeral are in the hash), and its cost rises to ~10⁸. It does NOT abolish the grind — a party posing as the extension chooses its own identity, nonce and ephemeral, and can always grind its own side inside the pairing window.
A malicious process can connect, but it can't produce a valid signature without the legitimate MCP's private key, and it can't cheaply fake a pair code that matches the one the user is being shown (see the grind above for what "cheaply" is worth).
Residual risk: A user who hits Approve without comparing the code is still vulnerable to social engineering. We can't fix that. The popup is intentionally interruptive and shows the domain in large type.
Chrome / Safari currently allow WebSocket connections from HTTP pages to ws://localhost. A malicious webpage could theoretically connect, send a hello, and try to use fetch against your sessions.
Defenses (layered):
- Reject non-localhost remote addresses at the TCP layer. The WS server binds explicitly to
127.0.0.1(not0.0.0.0). - Reject WS upgrades that come from a browsing context at all. What is admitted is an allowlist of two things: an extension scheme (
chrome-extension://,moz-extension://,safari-web-extension://), which is what the extension's own socket carries, and noOriginheader at all, which is what a Node peer dialing the concentrator sends. Everything else is a page and is refused with 403 — everyhttp(s)://origin,http://localhost:<port>,http://127.0.0.1:<port>andhttp://[::1]:<port>included; the opaquenullorigin that a sandboxed iframe, ansrcdocdocument, adata:URL or afile://page sends; and any other scheme, because a packaged app's UI (tauri://,capacitor://,ionic://,app://) is a browsing context too, can reach loopback, and can raise a pair prompt under a name of its choosing exactly as a web page can. The last branch of the gate is a refusal rather than anallow, so a scheme nobody has thought of is refused rather than admitted;localhostandnullwere admitted until §Which origins may open the socket, which is also where the development escape that puts those two back lives. - No-key handshake. Connections that don't send a valid
helloframe within 15 seconds get closed. A drive-by webpage script enumerating localhost ports gets nothing useful. - Browser-side Private Network Access (PNA). Chrome's PNA spec (Chrome 130+) requires public-origin pages to do a CORS preflight before connecting to private addresses. We refuse to honor any preflight, which kills the connection.
- Identity signature. Even if a webpage got past the above, it cannot mint a valid
sessionSigovermcpId || sessionNonce || sessionPub || answersExtNonce(helloSignaturePayload; up to protocol 3,mcpId || sessionNonce) without the legitimate MCP's private key — and an unknown identity falls into T1 (pair prompt). - Bounded buffering. The host caps an inbound WS payload at
MAX_FRAME_BYTES(42 MiB), so a connection that has not identified itself cannot make the process allocatews's 100 MiB default per message. The number is derived from the largest legitimate frame rather than chosen for comfort (protocol/src/seal.ts), because a payload overmaxPayloadis answered by CLOSING the socket with 1009 — and the extension's socket is the one every MCP on the concentrator shares. A conforming sender never reaches it: BOTH ends measure each frame against the same constant before sealing it and fail that single request instead — the extension insendInner(also the only size boundread_indexed_db,read_local_storage,read_domandread_dom_listhave — the last of which has no per-call row cap of its own beyond a declaredmaxItems, so an omittedmaxItemsis bounded only here), the MCP side inserver/src/frame-size.ts, where an oversize request throws to its own caller rather than meeting the host'smaxPayloadas a 1009 that would drop every sibling MCP's bridge.
The MCP server we trust gets compromised — npm package backdoor, or a malicious fork the user installs.
Defense 1 — per-MCP domain allowlist. Every MCP declares domains: [...] in its hello. The extension allows a fetch iff its URL host equals one of those entries exactly OR is a subdomain of one of them:
function isAllowedUrl(reqUrl: string, declared: string[]): boolean {
const u = new URL(reqUrl);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
const host = u.hostname.toLowerCase();
return declared.some((d) => host === d || host.endsWith('.' + d));
}opentable-mcp declares ["opentable.com"] → the extension rejects fetches to anything not under *.opentable.com. A backdoored opentable-mcp can still leak your OpenTable data (we can't fix that without making the MCP useless), but it cannot ALSO be used to read your bank, email, or Slack.
The same check applies to the tab that relays the request (init.tabUrl), not just the request URL — as it always did for graphql_query and legacy read_cookies. Up to 3.1.0 the fetch verb checked only the URL, so an MCP could name any open tab (the user's bank) as its relay and that tab's content script would attach the bank page's CSRF token to a request bound for the MCP's own domain. The viaTab guard in @fetchproxy/server runs in the MCP's own process and cannot be the enforcement point; the extension now refuses a tabUrl outside the declared domains.
If an MCP legitimately needs more than one domain (rare), it enumerates them: domains: ["honeybook.com", "hbsplit.com"]. There is no wildcard syntax.
Defense 2 — capability allowlist. Each MCP also declares a capabilities: [...] set. fetch is the default; read_cookies is opt-in. A backdoored MCP can't escalate to verbs the user didn't approve at pair time — the trust record stores the approved set, and the extension rejects any inner request whose op isn't in it.
Defense 3 — pair record locks both sets. If the MCP later declares a different domains or capabilities set (set-equality check, order-insensitive), the extension treats the trust record as missing and falls back to a re-pair prompt. So a compromised MCP that secretly widens its domain list to add a new target site or asks for read_cookies post-pair forces a fresh popup with the new ask in plain view.
Defense 4 — the pairing queue and approvals never touch storage that content scripts can write. The extension injects content scripts into every site, and content scripts can read and write chrome.storage.local. Up to 3.1.0 the pending-pair queue and the popup's decision (pendingPair → approvedPair, and dismissedScopeUpdate) travelled through storage.local, and the service worker trusted whatever approvedPair appeared there. A renderer compromise on any site could therefore have approved an identity of its choosing, with any domains and capabilities, without the popup. The queue and both decisions now live in chrome.storage.session, which Chrome restricts to extension pages and the service worker by default; the service worker listens for decisions there only, ignores those keys in storage.local, and deletes any left over from an older version at boot. Where storage.session is missing (Chrome < 102, excluded by the manifest's minimum_chrome_version) it fails closed: nothing is queued and nothing can be approved. Cancel removes the request from the queue, and the queue does not outlive the browser session — a request from before a restart is simply asked again by its MCP. That restriction is Chrome's: storage.session being closed to content scripts is unverified on Safari, pending the bridge's live Safari check (nullnet-app/contextmint-bridge docs/superpowers/plans/2026-09-25-extension-safari.md, Task 8, check 6), which treats a Safari that lets content scripts in as a release blocker. The stores the approvals write to are closed to content scripts too (fleet-audit #252). Before the vault the trust records (trustedMcps), the remote bridge targets (remoteBridges) and the dismissed scope-update hashes (dismissedScopeHashes) still lived in storage.local, so a content script could skip the approval channel entirely: write a trust record for an MCP identity of its choosing (auto-trusted, no prompt), delete a legitimate one, add a bridge for the browser to dial, or suppress a scope-update offer. All three now live in the extension origin's IndexedDB (contextmint-bridge/packages/extension-core/src/vault.ts), beside the extension's non-extractable identity keys (§T-fake-extension). Content scripts run in the page's origin, so they cannot open that database at all — they can neither read nor write any of it. This is a move, not a MAC over storage.local: a MAC would stop a forged record but not a deleted one, and a content script can always delete. What the code guarantees, and tests/vault-trust-integrity.test.ts asserts with storage.local as the attacker's reach:
- nothing a content script writes under
trustedMcps,remoteBridges,dismissedScopeHashesorextensionIdentityinstorage.localis read, except by the one-time migration below, which only an extension update onto a still-empty vault can trigger — a planted record is not trusted, a planted bridge is not dialled, a planted dismissal suppresses nothing, and losing the vault does not re-open the import (tests/vault-migration.test.ts); - clearing or overwriting
storage.localrevokes nothing and removes no bridge; - no trust record, bridge token, dismissal or key material is written to
storage.localat all, so there is nothing there to read; - the popup's bridge edits reach the service worker as a data-less
chrome.runtimemessage (remote-targets-changed) on which the worker re-reads the vault. A content script can send that message too; it causes at most a reload of what is already configured, and the worker ignores it from tab senders anyway.
Migration, and what it trusts. The import runs only when Chrome says the extension was just updated and the vault is still empty: chrome.runtime.onInstalled with reason update, arriving before the vault holds an identity. Nothing a page does can raise that event. It is deliberately not pinned to a "last legacy version": whichever release first ships the vault, the one before it kept state in storage.local, and a hardcoded version would strand every user of a storage.local release cut after it was written (3.2.1 was one). An update onto a vault that already has an identity authorises nothing, so no authorisation lingers for a vault lost later; the one case admitted beyond a genuine upgrade is a vault lost at the very moment of an update, which a page cannot schedule. The service worker records the authorisation in chrome.storage.session (closed to content scripts, like the pairing queue), so a worker killed mid-import finishes the job on its next start, and the import consumes it. It then imports whatever the three storage.local keys hold, together with the legacy identity, in one IndexedDB transaction. Each row is validated the way the live store validates it (malformed rows are dropped, not repaired), and the legacy keys are deleted. Existing pairings, bridges and dismissals survive, and nobody re-pairs. Stores are imported only alongside a legacy identity. An empty vault is not an upgrade signal. A fresh install, and a vault the browser evicted, a corruption, or a manual wipe of the extension's IndexedDB all look the same from inside the vault. Had emptiness triggered the import, a renderer that planted an identity (whose private key it knows) and trust records pinned to that identity would have had both installed the next time the vault was lost. In every one of those cases the extension mints a fresh identity and deletes whatever sits under the legacy keys without reading it. Because Chrome dispatches onInstalled only after the new worker has started, the worker's first vault access, if it finds the vault empty, waits a few seconds for the event before choosing. A worker that wakes to a lost vault, and so gets no event, waits out that bound and then mints fresh. The limit is the one any migration out of writable storage has: it cannot tell a legitimate pre-upgrade record from one a compromised renderer planted before the upgrade, because that storage was writable by design until now. If you have reason to think that happened, review the trusted-MCP list in the popup after upgrading and revoke anything you do not recognise.
Residual risk (Defense 4 as a whole): the boundary is now "code running as the extension" — the service worker and extension pages. A compromise of those (not of a site's renderer) reaches the queue, the trust store and the ability to sign with the identity key, as it always would. One availability caveat: IndexedDB, unlike storage.local, is quota-managed storage. If the browser ever evicted the extension's database, the identity and every pairing would be gone together, a fresh identity would be minted (never one read back from storage.local, per the migration rule above), and each MCP would prompt to re-pair. The MCP-side pin then refuses the new identity until cleared, as for a re-install. Eviction costs convenience, not security, because the upgrade-only import gate keeps a lost vault from being refilled out of content-script-writable storage. Two narrow windows remain. The first is the import itself: until an authorised upgrade's import completes, rows planted before it are imported, as described above. The second is the popup: if it is opened in the moment between an update and the worker's onInstalled, and finds the vault empty, it cannot see the event, so it mints a fresh identity. The upgrade's import then finds the vault initialised and skips, and the user re-pairs. Nothing is trusted that should not be.
Residual risk: Same-domain, same-capability exfil is unavoidable for the data the MCP is supposed to access. If an MCP gets compromised, you lose what it had legitimate access to. This is the same tradeoff as any third-party tool you grant access to a service.
read_cookies is the most-elevated read capability in the protocol, and it can return HttpOnly cookies, including the session cookie that signs the user in. It has two request shapes:
{ origin, keys }(0.3.0+, whatFetchproxyServer.readCookies({ keys })and@fetchproxy/bootstrapsend) — the background reads each named cookie withchrome.cookies.get, which sees HttpOnly cookies. That is the reason the shape exists: many sites keep the login session in an HttpOnly cookie, and the cookie-session MCPs (e.g. zola-mcp'susr, infinitecampus-mcp'sJSESSIONID, canvas-parent-mcp'spseudonym_credentials) lift exactly that cookie and replay it from Node. An MCP holding it can act as the user from anywhere, outside the browser, the extension and the domain gate, until the site expires the session.{ tabUrl }(legacy 0.2.0 shape,readCookies()with nokeys) — the content script returns the tab'sdocument.cookie, which omits HttpOnly cookies.
Earlier versions of this document said only non-HttpOnly cookies were visible. That was true of the legacy shape only, and wrong for the one every current MCP uses.
Defenses:
- Opt-in at the wire level.
read_cookiesonly works if the MCP declared it incapabilities. Omitting it disables the verb entirely; even theFetchproxyServer.readCookies()helper throws synchronously at the call site so an MCP author who forgot to declare it gets a clear error. - Approved at pair time. The popup labels
read_cookieswith a visible warning marker (acap-warn-styled list entry that decorates the label with a warning glyph) so the user notices the elevated trust. The trust record stores the approved capability set; a post-pair upgrade toread_cookiesforces a re-pair with the new ask spelled out. - Domain-bound. Like
fetch,read_cookiesmust target a tab on a declared domain — there's no way to read cookies from outside the MCP's allowlist. - Named cookies only. The
{ origin, keys }shape reads only the cookie names the MCP declared incookieKeysand the user approved at pair time; the pair popup lists every name and warns that they can include HttpOnly login-session cookies. It is NOT a defense against session exfiltration: if a declared name is the session cookie, the MCP gets the session. Only approveread_cookiesfor an MCP you would trust with your login on those domains.
Residual risk: A user who approves a pair with read_cookies is giving the MCP a powerful read primitive for the declared domains. The popup tries to make that visible; the trust record forces re-approval on change. There is no further defense — if you don't trust the MCP, don't approve the pair.
write_cookies is the only verb in the protocol that changes browser state. Everything else reads.
It exists for one failure class. Sites that rotate a credential cookie hand back a new value on every refresh; when an MCP refreshes and keeps the result to itself, the copy in the browser's cookie jar is dead, and the user is silently signed out of a tab they never touched — usually reported to them as "inactivity", which points nowhere near the cause. Reading cannot repair that. Measured on creditkarma.com: two MCP-side refreshes were enough to log out an untouched, freshly signed-in tab, and reloading the page does not recover it — only a full sign-in does (chrischall/creditkarma-mcp#119).
What it is NOT: a general cookie-authoring primitive. Four independent constraints, each enforced on the extension side:
- Capability.
write_cookiesis declared in the hello, approved at pair time as its own line, and stored in the trust record — so adding it later forces a re-pair with the diff UI, like any other capability change. The popup labels it "Overwrite cookies it can already read (can change your signed-in session)" rather than as a sibling of the read verbs, because it is not one. - Read scope. Every name must already be in the MCP's declared
cookieKeys. A write can never reach a cookie the user did not already approve for reading, so granting it cannot widen which cookies are in play — only what may be done to the ones already listed. - Domain. The origin is gated against the declared
domains, decided on the bare origin before any path is appended, exactly as the read path does. - Existence. The cookie must already exist. This verb refreshes a value in place; it cannot create cookies. That is what keeps it from becoming a cookie-injection primitive — an MCP cannot mint a session cookie for a domain, only replace a value the user's browser already holds.
Attributes are copied off the cookie being replaced (domain/path/secure/httpOnly/sameSite/expirationDate), so a write overwrites rather than shadows. domain is omitted for host-only cookies and expirationDate for session cookies, because passing either would silently widen the cookie's scope or lifetime.
Residual risk: an MCP granted write_cookies can set a declared cookie to a value of its choosing on a declared domain — including a value the user did not authorise, e.g. swapping a session for one the MCP controls. The containment is that this is limited to cookies the MCP could already read (and therefore already exfiltrate), so it grants no new access to the user's data; what it adds is the ability to alter the browser's state for those specific cookies. As with read_cookies: if you don't trust the MCP, don't approve the pair.
graphql invokes a page-declared GraphQL operation through the page's OWN Apollo client (window.__APOLLO_CLIENT__), in the page's MAIN world. This exists because some endpoints (OpenTable's RestaurantsAvailability) reject the isolated-world fetch path at the edge — the bot-detection sensor telemetry lives inside the page's own Apollo link chain, not on window.fetch — so the only way to clear it is to run the request through the exact code path the page itself uses.
What it is NOT: general MAIN-world JS execution. There is no page_eval, no arbitrary function call, no way to reach any object other than the page's Apollo client, and no way to run any operation the page hasn't already defined.
Defenses:
- It can only run operations the page already exposes. The extension carries NO hardcoded query text and NO persisted-query hash. It hooks
client.link.requestto capture the liveDocumentNodethe page's own Apollo client observed for a givenoperationName, then reuses that exact object viaclient.query(...). If the page's client has never observed the operation (e.g. the user hasn't loaded the relevant page yet), the bridge returns a typed "operation not yet observed on this tab" error rather than inventing a query. When several tabs match, the query is offered to each in turn until one owns the operation — this widens which tab may serve a request, never which operations may run: every tab is already on the declared-domain allowlist, and each still resolves the DocumentNode from its own page. - Declared-operation allowlist, approved at pair time. The MCP declares a
graphqlOps: [{ name, operationName }]list in its hello. Only operations in this list can ever be invoked; the popup surfaces the exactoperationNamevalues verbatim so the user sees precisely what will run. Widening or changing the declared set forces a re-pair with a diff, same as every other capability. - Capability-gated.
'graphql'must be declared incapabilitiesand approved at pair time — same opt-in mechanics asread_cookies/read_dom. The popup labels it with a warning marker. - Domain allowlist + host-or-subdomain tab match. Same as every other verb: the tab the query runs against must be on one of the MCP's declared
domains(or a subdomain of one). - Returns only the GraphQL response
data. The response is{ ok: true, data }wheredatais the parsed GraphQL response body — no page state, no DOM, no other globals, no ability to read anything the operation itself didn't return. - Per-call
variablesare supplied by the MCP, not the page. The extension never inspects or mutates them — it passes the MCP's object straight toclient.query({ query, variables }).
Residual risk: If the operation the MCP declared genuinely returns sensitive data (e.g. a booking-availability query that also echoes account details), that's the same tradeoff as any declared fetch endpoint — the user is trusting the MCP with what it's declared, not with arbitrary access. The mechanism cannot be used to invoke an operation the user hasn't implicitly exposed by using the page normally.
Known limitation (tracked, not yet fixed): the MAIN-world bridge script (capture-logger.ts) wraps client.link.request on any page it runs on that exposes an Apollo client — since audit #1003 only on hosts an approved MCP may reach (see §T7), but regardless of whether that MCP declared the graphql capability — the captured DocumentNodes stay in an in-process Map and are never exfiltrated, so this is a wider MAIN-world footprint (not a data leak) than the read-only CSRF sync this file previously did. It also polls for window.__APOLLO_CLIENT__ every 500ms for the lifetime of any tab that never gets one, with no give-up cap. Neither is a security hole, but both are worth tightening — see the PR #178 auto-review follow-up issue.
The same applies to the in-page fetch bridge added for fetch_in_page (T-in-page-fetch below): installFetchBridge registers its message listener on every page the MAIN-world bridge runs on — the approved hosts, see §T7 — regardless of whether the MCP approved there declared the capability. The listener creates no privilege — it runs in the page's own world with the page's own credentials, so anything it can fetch, page script could already fetch itself, and a request it never receives is a request it never makes — but it is MAIN-world surface present on tabs that will never use it. Gating installation on an approved capability is the tightening; it is not free, because the MAIN-world script is injected at document_start and does not know which capabilities were approved, so it needs a signal from the isolated world it does not have today. Tracked with the Apollo footprint above rather than fixed here.
fetch_in_page permits a fetch carrying init.inPage: true to be issued by the page's MAIN world instead of the content script's isolated world. Everything else is unchanged: same URL, method, body, injected CSRF header, same cookies, same domain allowlist.
It exists because some edge bot-managers distinguish the two worlds. Verified live on opentable.com: a GraphQL mutation POST returns 403 from the isolated world and 200 from the page, byte-for-byte identical, while GraphQL queries and REST writes pass from either — that 403 blocks every booking write. The precise signal was never confirmed (most likely the extension Origin/Sec-Fetch-Site Chrome attaches to content-script requests), so this is characterised by the observed world difference, not by a header we read.
What it is NOT: MAIN-world JS execution. Nothing is evaluated in the page. The bridge takes a URL, method, headers and body, calls fetch, and posts back {status, url, body}. It cannot read page globals, the DOM, or anything else.
Defenses:
- Per-request, not per-MCP. The flag is set on individual calls, so an MCP needing it for two GraphQL mutations keeps the isolated world for every other fetch. There is no mode that routes all traffic through the page.
- Capability-gated, refused early.
fetch_in_pagemust be declared and approved at pair time;handleFetchRequestrejectsinPage: truefrom an MCP without it before the request reaches any tab. - Strictly boolean on the wire. The validator rejects a non-boolean
inPagerather than coercing it, so a truthy string can never become a privilege decision. - Domain allowlist unchanged. The URL must still be on the MCP's declared
domains, with the usual tab matching. - Every field of the reply is validated. MAIN and isolated worlds share one message bus, so page script can post a forged
fetch-res. The isolated side matches a monotonicreqId, requires a positive integerstatusand stringurl/body, and enforces the sameMAX_RESPONSE_BODY_BYTEScap as the ordinary path — a malformed shape reaching the server's validators would otherwise tear down the WebSocket for every MCP on the concentrator. The status check is deliberately no weaker than the wire's ownassertPositiveInt: a merely-finite test would admit0, a negative and a fractional status, and the frame built from one is refused inside the host's message handler, whose catch closes that shared socket (1011). - No privilege is created in the page. The bridge runs with the page's own credentials on its own origin, so anything it can fetch, page script could already fetch itself.
Residual risk — and it is a real one. The isolated world is what makes an ordinary fetchproxy request tamper-resistant: page script cannot see or alter it. A request routed through the MAIN world gives that up. A compromised or hostile page on a declared domain can patch window.fetch and observe or modify a flagged request's headers and body — including the injected CSRF token — and can hand the MCP a fabricated response. That is strictly worse than the isolated-world path, and it is exactly why this is a separate capability the user approves rather than a silent fallback: an MCP declaring it is asking for that trade on the calls it marks. Do not declare it for requests that work without it.
In the 0.2.0 concentrator model, one MCP wins the WS port and acts as the host. Other MCPs on the same machine dial it as peers and tunnel their traffic through. A backdoored host MCP could read or tamper with peer traffic, exfiltrating their fetches or rewriting responses.
Defense — end-to-end AES-256-GCM. Each MCP runs its own crypto handshake with the extension, independent of the host:
shared = X25519(ownSessionPriv, peerSessionPub) # ephemeral × ephemeral, 3.0.0+
transcript = SHA256(mcpHelloNonce || extHelloNonce || mcpSessionPub || extSessionPub)
sessionKey = HKDF-SHA256(shared, salt=transcript, info="fetchproxy/4.0.0/session", L=32)
(transcriptHash() in @fetchproxy/protocol, one definition for the four call sites that compute it. Protocol 3 and earlier derived X25519(extEphemeralPriv, mcpIdentityX25519Pub) salted with the MCP's hello nonce — the MCP's half was its long-term key. docs/PROTOCOL.md §Session key derivation carries the full history, including that the info label did not track the protocol version until 3.0.0.)
The session key is derived between the peer MCP and the extension. The host never holds the IKM (the X25519 ECDH output) and cannot derive sessionKey. Every data frame is:
{ type: 'frame', mcpId, seq, iv, ciphertext }
where ciphertext is AES-256-GCM(sessionKey, iv, JSON(innerFrame), aad = frameAad(mcpId, seq, direction)) with a 16-byte GCM tag. The host sees mcpId (route hint), seq (replay protection), iv (per-frame nonce), and opaque ciphertext. It cannot decrypt and any tampering with the bytes fails GCM verification on the other end.
Replay protection: receivers track the highest seen seq per direction per session and reject anything <= lastInbound. WS guarantees ordering, so we don't have to tolerate gaps. Since 3.0.0 that counter is also authenticated rather than merely compared — see the 3.0.0 paragraph below and §T9, which is where the monotonic gate used to be described as the whole of the defense.
Residual risk: The host can drop or delay peer traffic (denial of service against peers). It cannot read or modify it. If the host crashes, peers race the port and one wins; the takeover is invisible to peers because trust is keyed on the identities and nothing about a session survives the socket it was made on — up to protocol 3 the derivation was a function of the identity keys alone, and since 3.0.0 it is a fresh exchange each time, which is strictly less state rather than more.
1.12.0 correction — this claim was not true on the peer path until 1.12.0. The paragraphs above describe the intent, and the intent held for the host's own session from 0.4.0. It did not hold for peers, and the reason is worth stating plainly rather than quietly fixing: a peer derived its session key from ready.extensionSessionPub and verified nothing — no signature, no identity. So the concentrator could put its own ephemeral public key in that frame, derive the same shared secret with the peer, and read and rewrite everything the peer believed was end-to-end encrypted, forwarding to the real extension to keep the illusion. "The host never holds the IKM" is only true when the peer authenticates whose ephemeral key it is deriving against.
1.12.0 narrows it with the material 0.4.0 already defined: the host relays the extension's hello to every peer, and a peer verifies Ed25519Sign(extPriv, ownHelloNonce || extHelloNonce) against it before deriving anything — the same check host.ts does for its own session. A concentrator cannot produce that signature without the extension's private key, so it can no longer invent an extension out of nothing, and (with the pin below) it cannot substitute a different extension identity either.
2.0.0 closes the rest of it. 1.12.0 left a residual worth naming, because it was the whole of the remaining attack: the signature covered the two nonces and NOT ready.extensionSessionPub, so a concentrator relaying the genuine hellos and the genuine signature — every nonce and identity intact — could still swap the ephemeral public key for one it held the private half of, derive the same shared secret, and read and rewrite the traffic. Everything the receiver checked still passed, because none of it committed to the key the ECDH actually used. That was true of the host's own session too, and had been since 0.4.0.
From 2.0.0 the ready sessionSig signs (mcpHelloNonce || extHelloNonce || extensionSessionPub) — one definition, readySignaturePayload() in @fetchproxy/protocol, used by the extension that produces it and both server paths that verify it. A relay would have to sign its own ephemeral key with the extension's Ed25519 private key, which is the thing it does not have. (3.0.0 appends a fourth field, the MCP's own ephemeral, so the same argument runs in both directions; see the 3.0.0 paragraphs below.)
This is a wire break (PROTOCOL_VERSION 2 → 3) and is handled as one: a v2 peer is refused at the hello rather than negotiated down. A negotiated variant was considered and rejected — a relay that can rewrite frames can rewrite the version it sees advertised, so the downgrade would be the attacker's to choose unless the negotiation itself were signed. All packages release together, and the extension must be reloaded with them.
One compatibility seam remains, deliberately: concentrators before 1.12.0 relay no hello, so a 1.12.0 peer behind an older host has nothing to verify against. It warns loudly and proceeds, because the port election picks the concentrator arbitrarily and refusing would break a mixed-version fleet at random. requireExtensionIdentity: true turns that warning into a refusal, and should be set anywhere the concentrator is not simply another MCP on the same laptop. The peer path's guarantee is only in force once every MCP on the machine is ≥1.12.0.
3.0.0 (protocol 4) retracts the claim above for one party: a host that also holds the identity. "The host never holds the IKM" was never a property of the host; it was a property of where the MCP's half of the exchange lived. Up to protocol 3 that half was mcpIdentityX25519Priv — a long-term key, on the MCP's disk — so the sentence held for a concentrator that routes frames for MCPs whose identity files it cannot read, which is the concentrator on a laptop, and held for nobody else. Anyone who held an MCP's identity and a recording of its frames could derive every session key in that recording afterwards: passively, retroactively, with nothing on either end to notice, and with the extension's ephemeral and both nonces sitting in the plaintext handshake where a recording already has them. That is not a hypothetical population. A hosted deployment provisions the identity — writing <identityDir>/<server-name>.json itself so every child of one registration presents the same identity to the extension (§T-fake-extension describes that shape and the read-only mount it implies) — so the party running the relay was also the party holding the decryption capability for everything the relay carried.
What v4 changes is which key the ECDH uses. The MCP now mints a per-session X25519 ephemeral of its own and carries the public half in its hello, covered by the hello signature (helloSignaturePayload); the extension's ready signature was widened to cover it too (readySignaturePayload, which has covered the extension's own ephemeral since 2.0.0), so after v4 neither side's contribution to the exchange can be substituted without a long-term private key the relay does not have. The key is X25519(ownSessionPriv, peerSessionPub), salted with a transcript over both nonces and both ephemerals, and the identities authenticate only. The private half of an ephemeral lives in the MCP process for the life of that session and is zeroed when it is dropped or displaced (dropOwnSessionAndEphemeral / installOwnEphemeral in packages/server/src/host.ts, and dropSessionEphemeral / installSessionEphemeral in packages/server/src/peer.ts — the property has to hold on both paths, so both are named here), because forward secrecy is false if the process keeps every ephemeral it ever minted.
Two smaller things come with it. The transcript salt closes the replayed-ready case: under v3 a replayed old ready re-established an old key with a reset counter, harmless only because the request id did not match downstream, and with both nonces and both ephemerals in the salt a replayed one derives a key nothing else holds. And mcpId, seq and the direction now ride in the AAD (frameAad), which is what makes the envelope's fields part of what the tag covers rather than three values a party in the path may rewrite; §T9 has the three rewrites that stops. GCM's additional data is authenticated and never transmitted, so the wire size is byte for byte what it was and MAX_FRAME_BYTES is unmoved.
This is a wire break, handled exactly as 2.0.0's was: PROTOCOL_VERSION 3 → 4, a v3 peer refused at the hello with no negotiated downgrade — for the reason #222 already wrote down, that a relay which can rewrite frames can rewrite the version it sees advertised — and every package plus the extension shipping together. The refusal names both protocol versions and a remedy for whichever end is behind — toward the extension, a ContextMint Bridge release that speaks the MCP's fetchproxy protocol number; toward another MCP, the minimum @fetchproxy/server release to upgrade it to (FetchproxyProtocolVersionError, packages/server/src/session-ready.ts; the extension's mirror in contextmint-bridge/packages/extension-core/src/lib/version-mismatch.ts) rather than being a thirty-second hang, which is what a version mismatch cost before 3.0.0.
What 3.0.0 does not close, stated here so it is not read into the paragraphs above: a host compromised while a session is live, with reach into the MCP process's memory, holds that session's ephemeral private key and its plaintext. Forward secrecy is a statement about past sessions and about a party holding an identity, not about a party sitting inside the endpoint. §What protocol 4 does not fix carries this and the rest.
Until 1.12.0 an MCP's answer to "which browser is this?" was "whichever one said hello". host.ts verified the ready signature against the identity presented in the same connection — proof that the connecting party holds the key it just showed us, and no evidence at all that it is the party we paired with — and peer.ts verified nothing (above). The extension has pinned the MCP since 0.2.0 (trustedMcps, keyed by the SHA-256 of its X25519 pub); nothing pinned in the other direction.
On loopback that asymmetry is the local trust boundary doing its job: the only thing that can reach 127.0.0.1:37149 is a local process, and a local process under your account is already inside the model. It stops being harmless the moment the far end of that socket can be something other than a process on this machine — a relayed or hosted bridge, for example. There, "whichever extension said hello" means a stolen relay credential buys a working session with every bridged MCP: the attacker's browser sees every request those MCPs make (URLs, headers, bodies) and answers them with content of its choosing, and there is no prompt anywhere, because the MCP has nothing to compare against and the user's browser is not involved.
Defense — the MCP pins the extension too. First contact is trusted and remembered (~/.fetchproxy/identity/<server-name>.extension-trust.json, mode 0600, written only after the ready signature proves the key). A different identity afterwards is refused before any session exists, on both the host and peer paths. This is the mirror of trustedMcps, and it uses the same TOFU shape the extension uses.
Where the pin lives is separable from where the identity lives. FETCHPROXY_TRUST_DIR, or trustDir in code, puts the pin somewhere other than the identity directory; unset, it is written exactly where it always was. This exists for one deployment shape, and in that shape it is not optional: a host that PROVISIONS the identity — writing <identityDir>/<server-name>.json itself so every child of one registration presents the same identity to the extension — mounts that directory read-only, and the pin is the one file this package has to write. A host that provisions the identity read-only MUST point FETCHPROXY_TRUST_DIR at a writable directory that survives a restart. Nothing fails loudly if it does not: the write is caught, logged as could not persist the extension pin, and the session continues — so the MCP trusts on first use again on the next boot, forever, and the defense above is decorative. A pair prompt that keeps coming back is the symptom to look for. A relative path is ignored with a warning rather than resolved against the child's working directory, which is not a place anybody chose.
The extension's own private keys are out of a content script's reach (since the vault, fleet-audit #253). The pin is only as good as the secrecy of the key it pins. Before the vault the extension kept its X25519 and Ed25519 private keys as base64 in chrome.storage.local["extensionIdentity"] — and the extension injects a content script into every site, and content scripts can read storage.local. One compromised renderer, on any site, could therefore lift the keys and answer as this extension to every MCP that had pinned it, over any bridge it could reach. The private key is now a non-extractable WebCrypto CryptoKey — one key, the Ed25519 signing key, since the bridge stopped keeping an X25519 private key at all (next paragraph) — persisted by structured clone in the extension origin's IndexedDB (contextmint-bridge/packages/extension-core/src/vault.ts, identity-keys.ts). That store is closed to content scripts — they run in the page's origin, so their indexedDB is the site's — and a non-extractable key cannot be exported by anything, extension code included: WebCrypto signs with it and never returns its bytes. Nothing about the identity is written to storage.local any more. The first load after upgrading imports the existing keys (checked to match their public halves) as non-extractable, deletes them from storage.local, and keeps the same identity, so no MCP sees a new extension and nobody re-pairs. What this does NOT cover: a key already copied out before the upgrade stays copied — a user who has reason to believe a renderer was compromised before upgrading to a vault build should re-install the extension (a new identity) and re-pair deliberately; and code running as the extension (a compromised service worker or popup) can still ask WebCrypto to sign with the key, which no storage choice can prevent — non-extractable means it cannot take the key away, not that it cannot use it while it is there.
The bridge keeps no X25519 private key (nullnet-app/contextmint-bridge #21, after the macOS spike of 2026-09-25). WebKit IndexedDB silently stores an X25519 CryptoKey as null — and nulls any object that contains one, with no error on put — while Ed25519 CryptoKeys and byte arrays round-trip (spec, Spike results — macOS). The bridge's first answer (nullnet-app/contextmint-bridge #11) was to probe the vault and store that key in whichever form survived: a CryptoKey where it could (Chrome), wrapped under a non-extractable AES-GCM key where it could not (expected on Safari), and plain PKCS#8 bytes as a last resort. The better answer is that the key has no caller in protocol 4: session ECDH is ephemeral × ephemeral (§T-host-MITM), and the long-term X25519 public key is an identity handle — the pair-code input and half of the MCP's pin — which needs no private half to be either. So the bridge no longer generates or stores an X25519 private key, on every browser, and there is no storage form to choose (contextmint-bridge/packages/extension-core/src/identity-keys.ts). Its identity is the X25519 public key, kept as the identity handle, plus the Ed25519 signing key, whose private half is a non-extractable CryptoKey on every browser, Safari included, and is what authenticates the extension's ready — the only proof of possession an MCP gets, and in protocol 4 the only one it needed. A vault written by an earlier build is migrated on the next wake (identity-storage.ts): it discards the X25519 private material unread, whichever form it was in (CryptoKey, wrapped bytes or PKCS#8 bytes), deletes the AES-GCM wrapping key, and keeps the X25519 public key and the Ed25519 key, so the identity every MCP pinned is unchanged and nobody re-pairs. What that does not cover: a PKCS#8 or wrapped copy read out of the vault before the migration stays read — but it is a key for which nothing in protocol 4 has a use; it derives no session key and signs nothing, and impersonating the extension still takes the Ed25519 private key. The MCP's pin compares both public keys (§T8), so the X25519 handle stays part of which extension this is while nothing proves possession of it.
Getting out of it deliberately. A legitimate extension re-install mints a new identity and would otherwise lock every MCP out at once, so the refusal names the exact file, fpx trust list / fpx trust clear <server-name> shows and drops pins, and FETCHPROXY_TRUST_NEW_EXTENSION=1 re-pairs an MCP whose source you do not own. Each of those is a deliberate act; none of them happen by accident.
Managed pin sets — a host provides the set, nothing is trusted on first use. First use is the right default on a laptop and the wrong one under a hosting provider that already knows which browsers belong to an account. There, every new registration, re-placed snapshot row, identity reset and unprovisioned row pins whichever extension completes a handshake first — including a thief's, attached with a stolen relay credential while the owner's browser is not — and a second legitimate browser is refused by every child that pinned the first, with no way out a hosted user can reach. So a host can set FETCHPROXY_EXTENSION_PINS=managed (or pass extensionPins: 'managed') and write <trustDir>/<server-name>.extension-pins.json: { "v": 1, "managed": true, "managedBy"?: string, "extensions": [{ "x25519Pub", "ed25519Pub", "label"? }] }, keys as canonical base64 of 32 bytes. In that mode, on the host and peer paths:
- the set is read on every handshake and never cached — it is how the host admits a newly confirmed browser and revokes an old one, and a cached copy would keep admitting a revoked browser until the child restarts;
- an extension is accepted only when one entry matches both its
identityX25519Puband itsidentityEd25519Pub, and itsreadysignature verifies under that Ed25519 key. One key is never enough: a browser holding a listed signing key could otherwise present anyone's X25519 handle, or the reverse; - nothing is written and nothing falls back to first use. The first-use pin file is neither read nor written, and
FETCHPROXY_TRUST_NEW_EXTENSION/allowNewExtensionIdentityare ignored (logged once); - a missing, unreadable or malformed file refuses every extension with
1008, logged asEXTENSION_PINS_MISSINGorEXTENSION_PINS_MALFORMEDonce per handshake, naming the path and never a key. An emptyextensionsarray is valid and refuses everyone — that is what "every browser revoked" looks like; - a set value of
FETCHPROXY_EXTENSION_PINSother thanmanagedis treated as managed, with a warning: a host that set the variable has declared it owns the pins, and a typo must not hand the child back to first use.
parseExtensionPins(text) and EXTENSION_PINS_FILE_VERSION are exported so the host validates what it writes with the function the child reads it with. fpx trust list shows a managed set as read-only, and fpx trust clear refuses it, naming the managing host: the host rewrites the file, so the way to change which browsers a managed MCP accepts is through that host. What this does not change: whoever can write the trust directory decides the set — the same local boundary as the first-use pin and the identity key — and which browsers the host lists is the host's decision, made outside this package (for mcp-host, only browsers whose token the account's owner has confirmed). Unset, behaviour is exactly the first-use pin above.
Residual risk: the pin is a file beside the MCP's own private key, so a local process that can write there can delete it and force a fresh first contact — the same boundary the identity key itself has, and not something a file store can close. What the pin closes is the case where the attacker is not on this machine and cannot touch that file. Trust-on-first-use also assumes the first contact is yours; on loopback that is near-certain, on a network endpoint it is an assumption worth naming.
A malformed-but-legitimate response degrades gracefully, not fatally. A peer's incoming frame can fail to open in two very different ways, and the code distinguishes them (openEncryptedFrameDetailed in packages/protocol/src/seal.ts):
- Decrypt failure (AES-GCM authentication fails) — the wrong session key or genuinely tampered ciphertext. Nothing about the plaintext can be trusted;
peer.tsdrops it silently, same as before (typically a straggler frame from a session that already rotated). - Validation failure after a successful decrypt — the ciphertext authenticated fine under the current session key (so this really is the live host forwarding the live extension's bytes), but the plaintext is malformed JSON or fails the wire schema. This is a genuine protocol bug, not a stale-key symptom, so
peer.tslogs it loudly (console.error) and, when the malformed response'sidis recoverable, routes a syntheticok:falsethrough the normal id-keyed dispatch — failing just that one pending call immediately instead of leaving it to hang until its own timeout with zero diagnostic signal, and without tearing down the connection over one bad response.
host.ts — the concentrator's single physical connection to the extension, multiplexing every MCP's traffic — still closes the whole WS on ANY validation failure (its message handler wraps everything in one try/catch; see host.ts:344-351). That remains a broader-blast-radius reaction than the peer path now has, but the concrete triggers found for it in this PR (the graphql errorPolicy bug, the download bytes:-1 sentinel, the graphql_query op-echo gap) were each fixed at the SOURCE — the extension no longer produces a response that fails validation for those cases — rather than by changing what host.ts does when one does. A future op-specific bug could still trip the same host.ts-side "close everything" behavior; this is a known, accepted broader risk, not one this PR closes generically.
2.1.0+. The extension can dial a configured wss:// relay in addition to ws://127.0.0.1:37149. This exists so an MCP that has to run somewhere other than the user's laptop — a hosted one — can still issue its requests from inside the user's signed-in tab (chrischall/mcp-host#162). It is the one feature in fetchproxy that widens the local trust boundary, so it is worth being exact about which parts move and which do not.
What does not change, and is the reason this is tractable at all. A relay is a concentrator, and a concentrator cannot read what it routes: every MCP derives its own AES-256-GCM session key with the extension, and the relay sees {mcpId, seq, iv, ciphertext}. That is §T-host-MITM, closed against a relay that forwards genuine frames by 2.0.0's ready signature over the ephemeral pub and, since 3.0.0, against a relay that keeps them by the ephemeral × ephemeral derivation. Every MCP arriving over a remote link still pairs with a code the user confirms, still declares domains and capabilities the extension enforces, and still holds a session key the relay never has.
Retracted, 3.0.0. This paragraph used to end: "Hosting an MCP does not give the host the user's cookies, requests or responses." That sentence was written about the relay code and read as a statement about the deployment, and as a statement about the deployment it was false for exactly the shape this feature exists for. Up to protocol 3 the session key was X25519(mcpIdentityX25519Priv, extSessionPub), so it was true only of a host that does not hold the MCP's identity — and a hosted deployment provisions identities (§T-fake-extension). A host holding a provisioned identity and a recording of the frames it relayed could derive every session key in that recording afterwards, passively, retroactively, with nothing to notice: cookies, requests and responses, all of it. The sentence is restored as true for recorded traffic from 3.0.0 / protocol 4, where the identity authenticates and the ephemerals agree. It is not restored for a host compromised while a session is live that can reach the child's memory, and it never said anything about the metadata a relay sees by construction — both are in §What protocol 4 does not fix. Do not re-shorten it to the old sentence.
What does change:
- which relay to trust is now a decision. A remote target is a URL plus a credential the user pastes in. Point it at something hostile and the traffic still cannot be read — but the MCPs behind it are MCPs that hostile thing chose, and each of them can ask the browser to pair. Approving a bridge is therefore approving what may ask, which is why the popup says so next to the form;
- the population that can reach this browser grows from "processes on this machine under my account" to "whatever that relay carries";
- pairing happens across a WAN, where the pair code is doing more work than it did on loopback: on a laptop "this is my machine" carried most of the argument, and here it carries none of it.
Defenses in this change:
- loopback is not configurable. Remote targets are strictly additional; nothing in storage or in the popup can remove or repoint the local link. A misconfiguration cannot take the bridge that has always worked;
wss://is required unless the host is loopback, so the hello, themcpIdand the pair-pending frame are not readable in transit. The inner frames were already sealed; the handshake around them was not;- the credential is its own field. Credentials in the URL are refused, because a URL is the part that gets pasted into a chat window;
- one
mcpId, one bridge. AnmcpIdis minted by the MCP, so it is not a name the extension can assume is unique across relays. It binds to the link its hello arrived on, a second link claiming a bound id is refused, and a frame arriving on the wrong link is dropped before it is decrypted. Otherwise a relay could claim an id it observed and have this browser answer for it; - per-link handshake. Each link sends its own hello with its own nonce and signs its readies over that nonce, so a ready cannot be replayed onto another bridge;
- teardown is per link. A relay dropping takes its own sessions with it and leaves the loopback ones alone.
One verb does not cross. download saves a file on the machine running the browser and answers with that machine's path, on the assumption the MCP asking reads the same disk. Over a remote bridge neither half holds — the path names a file the MCP cannot open, and the request would have a remote MCP writing bytes into the user's Downloads folder, which is the only thing in this protocol that leaves something behind on the machine. It is refused on a remote link, before the URL is examined, with a reason the calling MCP can print.
Residual risks, stated rather than implied:
- a relay can drop, delay or reorder — denial of service, never disclosure. The same residual the concentrator has always had, for the same reason;
- the MCP-side pin (§T-fake-extension) is what makes a stolen relay credential survivable. Without it, an attacker holding the credential pairs their own browser with the hosted MCPs and reads every request they make. It shipped in 1.12.0 and is a hard precondition for using this feature with anything hosted — not an improvement to it. A pin that cannot be WRITTEN is a pin that is not there: on a hosted deployment that provisions the identity read-only,
FETCHPROXY_TRUST_DIRis part of the precondition rather than a tuning option (§T-fake-extension); - the extension's trust store is keyed by MCP identity, not by bridge. An MCP identity the user already approved locally is auto-trusted if it appears over a relay. It cannot be impersonated — up to protocol 3 because the session derived via ECDH against that identity's X25519 public key, and since 3.0.0 because the hello that names the ephemeral is signed by the identity's Ed25519 key and that key is compared against the stored one (§T8, which is the half that had to move with the derivation) — but the trust decision is deliberately about who the MCP is and not which pipe it arrived through;
- a relay sees the metadata, and always will. Server name and version, declared domains and capabilities, cookie and storage key names, capture-header names, GraphQL operation names,
mcpIds, frame timing, frame sizes and per-registration byte counts. The server hello is plaintext by construction, because the extension has to render it in a pair prompt. v4 protects the contents of frames; it protects nothing about the shape of the conversation; - a relay in front of a 1 MiB message limit still tears down a large response. Cloudflare's WebSocket message limit is 1 MiB and the extension's response-body cap is
MAX_RESPONSE_BODY_BYTES= 5 MiB, so a bridged response between the two sizes breaks the browser link rather than failing one call. Fixing it is chunking at the protocol layer — a second wire change with a design of its own, deliberately not folded into v4.
Additive within protocol 4. A hosted relay (mcp-host) can send two frames of its own to an extension that asked for them in accepts: account-key, the account's Ed25519 public key, and account-attest, a signature by that key over accountAttestPayload() saying "the server hello that follows is registration R of account A, with consent mode C and approved-scope digest D" (PROTOCOL.md §account-attest). It exists so one browser pairing per account can stand in for one pairing per MCP. It is the first thing in this protocol a relay signs, so it is worth being exact about what the signature can and cannot do.
What a relay can vouch for. Only facts it binds in the payload, every one of which the extension checks against something it holds rather than something the relay sent: the link's own origin and extension-hello nonce, the hello's sessionNonce, mcpId and both identity keys (identityHash == sha256(identityX25519Pub) and identityEd25519Pub equal, never either), and the stored account, token and generation. Each of the fourteen fields is covered by a mutation test that alters it and requires the signature to fail (packages/protocol/tests/account-attest.test.ts).
Why a vouch is useless without the identity's private key. The attestation names the identity keys; it does not replace the proof of holding them. The hello it describes still carries a sessionSig that has to verify under that identityEd25519Pub, and the session key is still derived against an ephemeral that signature covers. An attestation replayed in front of an attacker's hello names keys the attacker cannot sign with; one minted for the attacker's own keys names an identity the account never provisioned. Because the payload binds mcpHelloNonce and answersExtNonce, one attestation covers exactly one hello on one link session: it cannot be replayed onto another link, a reconnect, or a second hello. notAfter is a sanity bound only.
What it never does. It never writes or widens a trustedMcps record (an existing record governs); it never changes request enforcement — undeclared origins and out-of-scope verbs stay refused exactly as without it; and a verification failure falls back to today's pair code, never to a silent refusal and never to a silent grant. silent consent needs an exact scopeDigest match against canonicalScope() of the scope as declared, which is injective over what the extension grants: it normalises only set order and hostname case, where the extension's own matcher does.
Loopback never honours it. Account trust is about the pipe, deliberately the inverse of trustedMcps' "who, not which pipe": the extension consults it only on the remote link whose origin, account and token it was approved for, and drops both frames on the loopback link. The loopback concentrator has no branch for either type, so it neither mints nor relays one in either direction (packages/server/tests/host-account-frames.test.ts). A relay that receives either frame from an MCP drops it — an MCP sending one is vouching for itself.
What a compromised relay gains (mcp-host account-pairing spec §5.3, the residual that design accepts). A relay that holds the account-key root, or whose signer is otherwise compromised, can attest any identity it also provisions as silent, on any site that identity declares, in every browser whose owner approved that account — where without account trust the same compromise gets impersonation only within scopes the person already approved, and a visible pair prompt for anything new. That is a real widening, and it is bounded, not closed:
- it reaches only browsers that were explicitly account-paired, one approval per browser, on that relay's origin;
- the first silent attach of a new identity is announced in the extension, and approvals record who approved and when;
- the attested identity has to be a provisioned registration with public keys the relay's operator can audit;
- the account key's generation only increases, and the extension refuses a generation below its high-water mark, so rolling a database column back does not revive an old key.
Residual risks, stated rather than implied:
- the relay is trusted for the facts it signs. The extension cannot check that the owner really approved scope D or chose consent C; it checks that the account key said so. The relay's own controls (fact MACs, a separate root secret) are mcp-host's to state, and are;
- metadata. The frames add the account id, slug, display name, a masked email and registration ids to what a relay already sees by construction (§T-remote-bridge).
Additive within protocol 4. A hosted relay whose account room admits several browsers, one serving at a time, can exchange four frames with an extension that asked for them in accepts (PROTOCOL.md §Room frames): bridge-role (relay → extension: "you serve" or "you are standby, label serves"), bridge-serve (extension → relay: "serve from this browser"), and the room-ping / room-pong heartbeat.
A relay that sends bridge-role can lie about the role, and gains nothing by it. The frame is display only: the extension shows it in the popup and keeps it in the link's session state, and nothing it enforces — trust records, account trust, scope grants, request enforcement, which MCPs may reach it — reads it. Saying "standby" to the browser that is actually serving, or "serving" to one that is not, misleads the person about where calls go; it does not change where they go, and the relay already decides where it routes every MCP's frames (§T-remote-bridge). Every MCP behind it still pairs, pins and encrypts end to end with whichever browser it reaches.
label is display text from the relay. It is bounded at 64 characters and refuses control and bidi-override characters, for the same reason as account-key's display strings: a right-to-left override can make one browser's name read as another's. The extension renders it as text, never HTML. It is the serving credential's display name and nothing else; a relay must not put a token id or an account id in it, and roomFrameText() refuses an extra member such as one. It copies the caller's object into plain data first, reading each own enumerable member once, and validates, gates and sends only that copy, so an accessor or a Proxy in the relay's own code cannot pass the check with one value and send another. Gating the validator's output alone would not be enough: the validators for some non-room types (the encrypted frame, ready) return their input object, not a rebuilt one.
bridge-serve asks; it does not take. The relay decides whether the browser is eligible (a confirmed browser of the account), rate-limits switches and logs them. A confirmed browser is already trusted by every MCP of the account, so letting it ask to serve adds no reach. An extension that never listed bridge-serve has its request dropped.
The heartbeat carries nothing. room-ping and room-pong are fixed texts; they tell the relay a browser is still answering and tell the extension nothing it acts on. A forged or withheld pong can only make a relay judge a browser live or stale, which the relay controls anyway.
Gating, and loopback. Each frame crosses only when the extension's own hello listed its accepts entry (room-pong rides on room-ping's), because an older extension refuses the types and closes the link 1002. The loopback concentrator has no branch for any of the four: it neither answers a room-ping nor relays any room frame between a peer and the extension (packages/server/tests/host-room-frames.test.ts). A relay drops any of them arriving from an MCP.
A user runs npx some-mcp-server from a random GitHub. It registers with fetchproxy declaring domains: ["yourbank.com"] and possibly capabilities: ["fetch", "read_cookies"].
Defense 1 — same as T1. The pair prompt is the gate. The popup shows the domain set and capability set; if you see yourbank.com in the prompt and didn't intend to install a banking MCP, you click Cancel.
Defense 2 — high-risk-keyword warning. The popup runs a substring match against each declared domain. If any of bank, gov, mil appears anywhere in the hostname, the popup decorates the entry with a WARNING: <domain> looks high-risk. line above the Approve button.
const HIGH_RISK_KEYWORDS = ['bank', 'gov', 'mil'];
HIGH_RISK_KEYWORDS.some((k) => domain.includes(k));Limitations of the high-risk heuristic — by design, not aspirations:
- It's a speed bump, not a filter. Substring matching catches obvious cases (
chase.comwon't fire, butchasebankonline.comwould) and misses non-obvious ones.creditkarma.com,wellsfargoadvisors.com,irs.gov(matches),paypal.com(does NOT match),coinbase.com(does NOT match),gmail.com(does NOT match) — the list is illustrative, not principled. - We deliberately don't ship a curated allowlist of "financial institutions" or "webmail providers" — that's a category we can't keep accurate, and a stale list gives false confidence.
- The defense the user must rely on is reading the domain list, not the warning marker. The warning exists to make the user pause; the actual gate is the explicit Approve click.
Defense 3 — a declared domain may not be a public suffix. domains entries are matched exact-or-subdomain on every side, so co.uk, github.io or vercel.app would claim every site anybody can register under them while the popup shows one plausible string. validateHello refuses such an entry (packages/protocol/src/public-suffix.ts, isPublicSuffix); HOSTNAME_RE already refused a bare TLD. The FetchproxyServer constructor refuses it too, through the same imported predicate — two implementations of "is this a public suffix" is how the two ends come to disagree — because validateHello runs at the FAR end, where the extension's background/socket.ts answers a ProtocolError by dropping the frame with a console.warn into the service worker's own console: the profile's author saw a bridge that never answered and no diagnosis they could reach. The constructor's refusal is not the security boundary (a hostile MCP does not call it); it is the one that reaches whoever wrote the declaration. It is a heuristic, not the Mozilla Public Suffix List: every single-label host, a generative rule for <administrative label>.<two-letter ccTLD> (co.uk, com.au, gob.mx), and a hand list of the ccTLD levels that rule cannot reach (ne.jp, me.uk) plus the well-known vendor suffixes (github.io, herokuapp.com, pages.dev, s3.amazonaws.com). Consistent with the point above about curated lists, a public suffix this file has never heard of is accepted — the long tail, the PSL's wildcard/exception rules, and enormous-but-registrable parents like amazonaws.com are not covered, and the module comment says so entry by entry. Reading the domain list remains the defense.
A compromised MCP could fetch a URL that navigates the tab to an attacker-controlled page (e.g., a redirect chain). If the tab navigates, subsequent fetches would go through a different document.
Defense. The extension's content-script fetches are fetch(url, { credentials: 'include' }) from the page MAIN world, but they do NOT navigate the tab. Redirects are followed by the fetch API itself — the document the script runs in stays put. So this isn't a real attack vector unless the MCP server explicitly asks the user to navigate, which isn't a protocol verb.
The extension's service worker parses every WS frame. A bug (prototype pollution, JSON parser issue, unhandled exception) could let a malicious local process pwn the worker.
Defenses:
- Schema validation on every frame. A dependency-free validator in
@fetchproxy/protocolcheckstype, required fields, types, base64 well-formedness, hostname syntax, capability-string membership. Unknown frames close the WS with code1002. - Reject
__proto__,constructor,prototypeas JSON keys. Standard prototype-pollution defense. Also reject objects with a non-Object.prototypeprototype. - Bound request/response body size. Refuse fetch requests with
init.body.length > 1 MB. Refuse response bodies larger than 5 MB. - Treat any parse / validation error as a connection-killer. If a frame fails validation, close the WS with code
1002. Reconnect logic kicks in. - GCM authenticity check. Tampered encrypted frames fail decryption and close the session.
Some target sites (OpenTable, Resy) use CSRF tokens that live on window.__CSRF_TOKEN__ in the page MAIN world, which the isolated-world content script cannot see. While serving a fetch the background has approved, the content script asks the MAIN-world script for the token over the window's message bus (readPageCsrfToken / installCsrfBridge) and sets it as x-csrf-token.
Concern (and the history). Up to 3.1.0 the MAIN-world script copied the token into a <html data-fetchproxy-csrf> attribute every 2 s on every site the user visited, used or not. That moved a secret which lived only in a JS variable into the DOM, where CSS attribute selectors can read it: a site with an HTML/CSS injection but a CSP that blocks script could exfiltrate it with html[data-fetchproxy-csrf^="a"]{background:url(...)}. It also fingerprinted the extension on every page. The token is no longer written to the DOM at all.
Detectability — where the bridge runs (audit #1003). Removing the attribute did not remove the fingerprint: the CSRF, Apollo and in-page fetch bridges all answer a same-window postMessage carrying the private __fetchproxy marker, so a page script that posts {__fetchproxy: 'csrf-req', reqId: 1} gets a reply, and a page with an Apollo client can see its link was wrapped. Nothing about the message bus can hide that — the page sees every message the isolated world sends. So up to 3.2.x, when capture-logger.js was a manifest content script on <all_urls>, every site the user visited could detect the extension, and a bridge extension is exactly what anti-bot vendors flag as automation. It is now registered at runtime (main-world-bridge.ts, chrome.scripting.registerContentScripts, still document_start in the MAIN world) only on the approved hosts of MCPs the user has paired — the same exact-host-or-subdomain rule the request allowlist uses — updated on every approval and revoke and re-asserted on every service-worker start. What remains, stated rather than hidden: a site an MCP is approved for can still detect the bridge, and a tab that loaded while its host was approved keeps the bridge until it reloads after a revoke. The isolated-world content.js still runs on every page, but it answers nothing a page can send.
Defense — on demand, same window, approved fetches only. The request/reply pair only travels on the page's own window (event.source === window, posted to the window's own origin), whose scripts already hold window.__CSRF_TOKEN__, so this adds no same-origin exposure. It only happens inside a fetch that passed the background's domain gate — which, since the release after 3.1.0, covers the relaying tab (tabUrl) as well as the request URL, so an MCP cannot pick a tab on a site it was not approved for and collect that site's token on its request.
We document this so future contributors don't expand the CSRF pattern to expose tokens cross-origin or persist them in the DOM.
A malicious local process connects and sends a hello claiming to be opentable-mcp v0.10.0 with domains: ["opentable.com"] to ride on a previously-approved trust record.
Defense — identity-key signature. The hello carries identityX25519Pub, identityEd25519Pub, a fresh sessionNonce, this session's ephemeral sessionPub, and sessionSig = Ed25519Sign(identityEd25519Priv, mcpId || sessionNonce || sessionPub || answersExtNonce) (helloSignaturePayload; up to protocol 3 the payload was mcpId || sessionNonce). The extension verifies the signature on every connection.
A bare hello with a stolen identityX25519Pub (it's public!) won't work — the validator demands a valid signature, which requires the private key. The trust record is keyed by hex(sha256(identityX25519Pub)), so even a name/version-perfect impostor with a different key hits the pair prompt.
3.0.0 — the key the record is keyed on is no longer the key that proves possession, so both halves are now compared. Under protocol 3 the extension's trust match could get away with comparing identityX25519Pub alone, and did: the session key was derived against that key, so an impostor presenting a stolen X25519 public half and an Ed25519 key of its own would have passed the signature check (it verifies against the key in the same frame), hit the genuine record on the X25519 hash — and then been handed a session it could not open a single frame of, because the extension derived against the stolen public key and the impostor does not hold its private half. The ECDH was the proof of possession, and the missing comparison was belt-and-braces.
v4 inverts that. The session key comes from an ephemeral, so a signature under identityEd25519Pub is the only thing binding that ephemeral to a trusted identity, and an attacker holding nothing but public values would have auto-trusted on the X25519 hash with no prompt and read everything. So the extension now requires the stored identityEd25519Pub to be the key that signed the hello (the scopeIdentityChanged disjunction in contextmint-bridge/packages/extension-core/src/background/hello.ts), a mismatch falls through to needs-pair rather than a silent reject, and an absent stored value mismatches rather than being normalised to the hello's, which would make the check a tautology. The MCP side has held both keys to this rule since 1.12.0 and says why in decideExtensionTrust (packages/server/src/extension-trust.ts): "Both keys, not either: a rotation of one is a different extension, and accepting a half-match would let an attacker keep the ECDH key it needs while swapping the signing key it doesn't hold, or the reverse." After 3.0.0 that sentence is true of both ends. Nothing migrates and nothing re-pairs: identityEd25519Pub is a required field of TrustRecord and is written unconditionally on approval (contextmint-bridge/packages/extension-core/src/trust-store.ts), and a record old enough to lack it predates the 0.4.0 extension-identity pin beside it, which already forces such a record to re-pair.
The legacy 0.0.x port-based trust unit ((port, server-name, domain)) is gone. Trust is now per-identity-key, full stop. Port changes, restarts, and MCP package renames don't invalidate trust; key compromise does.
Residual risk: If the legitimate MCP's private key on disk is stolen (~/.fetchproxy/identity/<server-name>.json mode 0600 — but a fully-pwned account can read it), an attacker can impersonate it. Goes back to §Local trust boundary.
Two MCPs both send { id: 1, ... }. Could responses route to the wrong server? Could a recorded frame be replayed?
Defense — per-session keying. Each MCP has its own sessionKey (different per-connection because both ends mint a fresh ephemeral and the transcript that salts the derivation covers both nonces; up to protocol 3 the freshness came from the MCP's sessionNonce alone). Frames addressed to MCP A cannot be decrypted by MCP B even if the host misroutes them. The host routes by mcpId; within an mcpId, request ids are scoped per-connection.
Replay defense — AAD over the frame's identity, 3.0.0+, and a monotonic seq under it. Take the weaker half first, because this section used to claim it as the whole: mcpId and seq ride on the envelope, outside the ciphertext, and up to protocol 3 nothing the GCM tag covered committed to either. So the monotonic gate below was a check on a number the sender did not sign — a party in the frame path could replay a recorded frame under a bumped seq and sail through it, re-file a frame under another MCP's mcpId on the shared concentrator socket, or reflect a frame back at its sender, which was harmless only because the two dispatchers ignore each other's inner types. "A replayed frame is dropped" was true of a frame replayed verbatim and of nothing else.
Since 3.0.0 every frame is sealed under frameAad(mcpId, seq, direction) — utf8('fetchproxy/4/frame' ‖ NUL ‖ mcpId ‖ NUL ‖ decimal(seq) ‖ NUL ‖ direction), with direction a required parameter on both the seal and the open so no call site can omit it and no default can be wrong. All three rewrites now fail the tag rather than being caught, or not caught, by a check downstream. The AAD is authenticated and never transmitted, so this costs nothing on the wire.
The monotonic seq, which still does its own job. Receivers reject any frame whose seq is <= lastInbound. WS guarantees ordering, so legitimate frames always increase. A frame replayed verbatim from earlier in the session is dropped. lastInbound moves only once a frame has AUTHENTICATED, because advancing on the way IN lets anything that can put bytes on the socket name a seq without holding the session key, and every genuine frame behind it carries a lower number and is then dropped as a replay. The gate is therefore a synchronous CLAIM taken before the AES-GCM open (claimInboundSeq) and an answer recorded after it — commitInboundSeq when the frame authenticated, releaseInboundSeq when it did not. The claim is what makes a seq exclusive while its frame is in flight: a bare freshness question changes nothing, so two identical frames read in ONE pass of the receiver both passed it before either could commit, and both were processed. On the extension that is a second EXECUTION of the request rather than a dropped one, since the dispatch behind the gate has no per-id guard of its own.
The extension is distributed via the Chrome Web Store (eventually) and built-from-source today. Either path could ship a compromised update.
Defense: Standard store-level review where applicable; for users in the highest-paranoia tier, build from source (contextmint-bridge/packages/extension-chrome/build.ts produces a loadable unpacked extension; the bridge repo's GitHub Releases also ship a zip built by its release workflow).
Extension major-version bumps invalidate the trust store (force re-pair on every MCP); patch and minor bumps carry trust forward. The trust record schema is versioned on read; pre-capability records (no capabilities field) are normalised to ["fetch"] for back-compat.
Since #418 the extension hello may carry unavailableCapabilities — the capabilities this browser cannot serve — so the extension can grant an MCP the servable subset instead of refusing it whole (PROTOCOL.md §Capabilities this browser cannot serve). The field is unsigned. The extension hello has never carried a signature (the extension's proof of possession is the ready's, over readySignaturePayload), and putting the list inside a signed payload would change what an existing signature covers — a wire break. So it is advisory, and anything that relays frames between the extension and an MCP — a remote bridge, or the host itself for its peers (§T-host-MITM) — can add entries or strip them. The same was already true of platform, which this change now also surfaces in bridgeHealth() and in error hints.
What tampering can do, checked against the code:
- Add entries. The MCP refuses those verbs locally (
refuseIfUnavailableinws-server.ts) withFetchproxyCapabilityUnavailableError, whose hint blames the browser — wrongly, in this case. That is a denial of service, and a party relaying frames can already deny service by dropping them; the only new effect is the misleading message. - Strip entries, or the whole field. The MCP sends the request over the encrypted session. The extension decides from its own runtime API check, not from anything on the wire, and answers with
code: "capability_unavailable"inside an authenticated frame — the same typed error, now tamper-proof. - Gain a capability. Not possible. The field flows only extension → MCP; the extension never reads it off any frame, and what it grants is
declared − (its own probe). On the MCP it feeds exactly two things: the local refusal above and the health report. It must never feed trust, pinning, the pair code, key derivation or any grant — the rule for any future reader of it.
Names the MCP does not know are dropped, and the validator bounds the list (≤ 32 entries of 1–64 characters), so an injected list cannot grow memory or smuggle a name into anything that switches on it.
Residual risk: a party already positioned to relay the extension's frames can make an MCP believe fewer capabilities are available than really are, and make its error text blame the browser for it. That is a downgrade to refusal, never an escalation. If the list ever needs to be tamper-evident, the additive fix is an encrypted session-info frame (extension → MCP, inside the session, sent only to servers that list it in accepts) carrying the same list; this change does not add it.
3.0.0 is the largest change to this document's crypto since 0.2.0, which makes it exactly the version whose residuals are worth writing down before somebody infers them away. v4 gives forward secrecy for past sessions and binds every frame to its mcpId, its ordinal and its direction. That is all it gives. Each of the following is true the day it ships:
- A compromised endpoint reads everything. Forward secrecy is a statement about past sessions and about a party holding an identity. A relay or host compromised while a session is live, with reach into the MCP process's memory, holds that session's ephemeral private key and its plaintext — as does anyone who runs the machine the child runs on. v4 does not change that and must not be read as changing it.
- v4 empties what an identity is worth against a recording; it does not make fewer parties identity holders. A deployment that provisions identities for its hosted MCPs still holds them, and narrowing who holds one is a separate change that this break neither performs nor makes unnecessary. The two are independent and both are wanted.
- The pair code is stronger and still grindable. Since 3.0.0 it commits to a transcript and is eight digits, so the grind is online, per-pairing and ~10⁸ where v3's was offline, once and ~10⁶ — but a party posing as the extension still chooses its own identity, nonce and ephemeral, and can grind its own side inside one pairing window against a user who compares only the digits. §T1 has the derivation and the arithmetic.
- A relay still sees the metadata. Names, versions, declared scope, key names, operation names,
mcpIds, timing, sizes, byte counts — the server hello is plaintext because the extension must render it in a pair prompt. v4 protects frame contents, not the shape of the conversation. §T-remote-bridge carries the list. - The extension is trusted on first use over a relay unless the host manages the pins. The MCP-side pin (§T-fake-extension) is what makes a stolen relay credential survivable. By default it is trust-on-first-use, and whether a given hosted registration has a writable
FETCHPROXY_TRUST_DIRthat survives a restart is a deployment question, not a protocol one — a pin that cannot be written is a pin that is not there. The alternative is a managed pin set (FETCHPROXY_EXTENSION_PINS=managed, same section): the host writes the set of browsers it has admitted, the child reads it on every handshake and never trusts on first use, so a new registration, a re-placed row or an identity reset no longer pins whichever extension arrives first. That moves the question from "who got there first" to "what did the host list", which is a decision the host has to make well; it is not a protocol change, and a deployment that does not set it keeps the first-use residual in full. - A large bridged response still tears down the browser link where a 1 MiB relay message limit sits under the 5 MiB body cap. That is chunking at the protocol layer, i.e. a second wire change, and it is deliberately not folded in here.
- Nothing about what a paired MCP may do has changed. Domains, capabilities, the write verb's narrowness, the pair prompt a user may click through — all exactly as they were. v4 is a change to who can read the traffic, not to what the traffic is allowed to ask for.
Stated plainly so there are no surprises:
- A user account that's already compromised. Root/sudo or full user privileges → malware can read
~/.fetchproxy/identity/<server-name>.json, install a malicious extension, or read your Keychain. fetchproxy isn't designed to defend against a fully-pwned account. - A user who clicks Approve on every prompt. We make the prompt loud, but we can't override active consent.
- An MCP that lies about what it does within its declared scope. If
opentable-mcpactually exfiltrates your reservations to its author over a legitimate-looking opentable.com URL, the protocol can't tell. Trust the MCPs you install. - Multi-user shared machines. Localhost is the trust boundary. Don't share user accounts.
- State-actor-grade attackers. Out of scope.
These were open in the 0.0.3 / 0.1.x security doc and have since been answered by the implementation.
- Shared-secret token in WS upgrade? Resolved: no, identity keys instead. Each MCP holds a long-term Ed25519/X25519 keypair and signs a fresh payload per connection:
mcpId || sessionNonce || sessionPub || answersExtNoncesince 3.0.0 (helloSignaturePayload), andmcpId || sessionNonce— which is what this answer said when it was written, and what was true up to protocol 3 — before it. Stronger than a static token (no replay) and doesn't require a per-port config file. See T8. - Trust scope — per-port or per-
(port, name, domain)? Resolved: per-identity-key. Stricter than either of the original options. The trust record is keyed offhex(sha256(identityX25519Pub)); thedomainsandcapabilitiessets are stored alongside and any change forces a re-pair. See T1 + T3.
- High-risk-domain heuristic: substring vs. curated list. Status: substring with
bank | gov | mil. Curated lists go stale and give false confidence; substring is honest but imprecise. We may add a more careful classifier when there's a real-world miss that hurts. See T4. - Paranoid mode that re-prompts on every fetch? Status: not implemented. Too prompt-noisy for the default. Could become a per-domain "confirm every request" toggle in a future release.
- Extension-side identity key. Status: answered, and this entry was stale — recorded rather than deleted, because it read as a description of current key material and after 3.0.0 it would read as a description of v4's. It said the extension had only a per-connection ephemeral, so a peer-MCP could not pin "this is the same extension as last time". The extension has had a long-term X25519 + Ed25519 identity since 0.4.0, and pinning it is exactly what §T-fake-extension shipped in 1.12.0. The ephemeral did not go away and is not the identity: since 3.0.0 both ends mint one per session, the identities authenticate and the ephemerals agree (§T-host-MITM).
Security issues: open a private GitHub Security Advisory on chrischall/fetchproxy.