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 0.2.0. Two structural changes vs. 0.0.x / 0.1.x bear on the threat model:
- 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 derived from each MCP's own identity key. This adds T-host-MITM as an explicit threat and closes it.
- 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.
| 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. |
| 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. Any change → re-pair prompt. |
| Arbitrary JS execution in your tabs | The protocol has no eval_js, inject_script, or equivalent. Closed by design. |
| Storage exfiltration | read_storage, read_indexeddb, etc. don't exist. read_cookies is a deliberate, narrow opt-in. |
| 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.
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, and an Ed25519 signature over mcpId || sessionNonce. The extension verifies the signature, then looks up the identity hash in chrome.storage.local["trustedMcps"].
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:123-456[Approve] [Cancel]
The 6-digit pair code (SAS — Short Authentication String) is SHA256(identityX25519Pub)[0..3] mod 1_000_000 formatted as XXX-XXX. The MCP prints it to stderr; the extension shows the same value. The user compares them and clicks Approve. Same identity → same code, every time. Approving stores the identity-key hash plus the declared domains and capabilities set in chrome.storage.local; subsequent connections with the same hash skip the prompt.
A malicious process can connect, but it can't produce a valid signature without the legitimate MCP's private key, and it can't fake a pair code that matches a code the user is willing to approve.
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 whose
Originheader is set to a public origin. The extension is invoked fromchrome-extension://...and sends anullor matching origin. Anything else is rejected. - 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 || sessionNoncewithout the legitimate MCP's private key — and an unknown identity falls into T1 (pair prompt).
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.
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.
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 capability in the protocol. The extension reads document.cookie from a tab on a declared domain and returns the string. Only non-HttpOnly cookies are visible to page JS — the HTTP-only session token that actually authenticates the user is NOT included — but the value still contains things like CSRF tokens and "is logged in?" markers and is enough to break a site's auth bootstrap in some designs.
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. - HTTP-only cookies are not exposed. The browser refuses to surface them to page JS. fetchproxy doesn't have a side channel to read them either — it relies on
document.cookie, same as any in-page script.
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.
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(extEphemeralPriv, mcpIdentityX25519Pub)
sessionKey = HKDF-SHA256(shared, salt=sessionNonce, info="fetchproxy/0.1.0/session", L=32)
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)) 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.
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 + session derivation are stateless given the identity keys.
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.
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. The extension syncs this to a dataset attribute so the isolated-world content script can read it before issuing a fetch.
Concern. That dataset attribute is readable by any script running on the page, including any third-party script the target site loads.
Defense — same-origin assumption. opentable.com → opentable.com. The third-party scripts in question are loaded by opentable.com itself; the CSRF is THEIR CSRF, used to call THEIR endpoints. Exposing it to same-origin scripts isn't a new exposure — they'd find it on window.__CSRF_TOKEN__ anyway.
We document this so future contributors don't expand the CSRF-sync pattern to expose tokens cross-origin.
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, and sessionSig = Ed25519Sign(identityEd25519Priv, 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.
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 of the fresh sessionNonce). 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 — monotonic seq. Receivers reject any frame whose seq is <= lastInbound. WS guarantees ordering, so legitimate frames always increase. A replayed frame from earlier in the session is dropped.
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 (packages/extension-chrome/build.ts produces a loadable unpacked extension; the GitHub Release also ships a fetchproxy-extension-${VERSION}.zip built by the 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.
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
mcpId || sessionNonceper connection. 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: ephemeral per-connection. The extension generates a fresh X25519 keypair every time it connects. That's fine for confidentiality, but means a peer-MCP can't pin "this is the same extension as last time" if the WS reconnects. Probably never needed; flag for revisit if a use case appears.
Security issues: open a private GitHub Security Advisory on chrischall/fetchproxy.