Research
Researchvulnerability5 min read

AIPex's localhost daemon let any website control your browser through a WebSocket

AIPex's MCP daemon on 127.0.0.1:9223 accepted WebSocket connections from any origin, letting malicious web pages invoke 30+ browser automation tools. A 39-line fix adds origin validation at the single upgrade handler.

AIPex's localhost daemon let any website control your browser through a WebSocket

AIPex is a Chrome extension that gives AI agents browser automation capabilities through the Model Context Protocol (MCP). It runs a local daemon on 127.0.0.1:9223 that bridges the extension, a CLI client and an MCP server over WebSocket connections. The daemon's upgrade handler accepted connections from any origin, with no authentication and no validation. Any website the user visited could open a WebSocket to ws://127.0.0.1:9223/bridge and call every tool the extension exposes.

I identified the vulnerability, submitted a fix with 14 tests and the PR was merged on 3 April 2026.

What made this exploitable

The vulnerability is cross-site WebSocket hijacking (CSWSH), classified as CWE-346 (Origin Validation Error). The attack surface is narrow to describe and wide in impact.

The daemon in mcp-bridge/src/daemon.ts creates an HTTP server and three WebSocketServer instances, all configured with { noServer: true }:

  • /extension for the Chrome extension
  • /bridge for the MCP bridge
  • /cli for the command-line client

All three share a single httpServer.on("upgrade", ...) handler. That handler inspected the URL path to route to the correct WebSocket server, but never checked who was asking. No Origin header validation. No authentication token. No CORS equivalent for WebSocket.

Binding to 127.0.0.1 prevents remote network access, but it does nothing against browser-based attacks. When a user visits a web page, the JavaScript on that page runs in the browser process, which sits on the loopback interface. The browser attaches an Origin header to every WebSocket connection initiated from a page, but the daemon never looked at it. A malicious page could connect just as easily as the legitimate extension.

The exploit is trivial:

const ws = new WebSocket("ws://127.0.0.1:9223/bridge");
ws.onopen = () => {
  ws.send(JSON.stringify({
    jsonrpc: "2.0", id: 1, method: "tools/call",
    params: { name: "create_new_tab", arguments: { url: "http://evil.com/phishing" } }
  }));
};

That is a complete, working proof of concept. A user with the AIPex daemon running visits a compromised or ad-injected page and the attacker gets access to roughly 30 MCP tools, including create_new_tab (open arbitrary URLs), capture_screenshot (exfiltrate visual data from the active tab), download_text_as_markdown (exfiltrate page content), execute_skill_script (run arbitrary skill scripts) and computer (simulate mouse and keyboard input). No user interaction required beyond loading the page.

I searched the repository for any other mitigations: no auth patterns, no CORS headers, no token validation, no rate limiting. The /health HTTP endpoint returns status information but is CORS-blocked from cross-origin reads by the browser. The WebSocket paths had no equivalent protection.

The fix

The fix is 39 lines, purely additive, touching a single file. No existing behaviour is modified.

An isOriginAllowed() function gates the upgrade handler:

function isOriginAllowed(origin: string | undefined): boolean {
  // Node.js WebSocket clients don't send an Origin header — allow
  if (!origin) return true;
 
  // Browser extensions are trusted clients
  if (origin.startsWith("chrome-extension://")) return true;
  if (origin.startsWith("moz-extension://")) return true;
 
  // Reject all web page origins (http/https) — prevents CSWSH attacks
  return false;
}

The origin check runs at the top of the httpServer.on("upgrade") handler, before path routing:

httpServer.on("upgrade", (req, socket, head) => {
  const origin = req.headers.origin;
 
  if (!isOriginAllowed(origin)) {
    log(`Rejected WebSocket upgrade from origin: ${origin}`);
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    socket.destroy();
    return;
  }
 
  // ... existing path routing continues
});

Rejected connections get a raw HTTP/1.1 403 Forbidden and immediate socket destruction, before the WebSocket handshake completes. The attacker's JavaScript receives an error event, not a connection.

Why this works

The defence relies on a browser-enforced security property: browsers always attach an Origin header to WebSocket connections initiated from web pages, and JavaScript cannot suppress or forge it. This is not a convention. It is specified behaviour enforced by every major browser engine. A web page connecting to ws://127.0.0.1:9223 will always send Origin: https://attacker.example (or whatever the page's origin is), and the daemon can reject it.

Node.js WebSocket clients (the bridge, the CLI) do not send an Origin header by default. The Chrome extension sends chrome-extension://<id>. Both are explicitly allowed. The allowlist is a closed set: no origin, chrome-extension:// or moz-extension://. Everything else is rejected.

Why a single chokepoint matters

All three WebSocketServer instances use { noServer: true }, meaning none of them perform their own HTTP upgrade. The single httpServer.on("upgrade") handler is the sole entry point. Placing the origin check here means there are no parallel bypass paths. A new WebSocket path added in the future will inherit the protection automatically.

Testing

Fourteen tests validate the fix across three categories:

CategoryTestsExamples
Allowed origins3No header, chrome-extension://abc, moz-extension://abc
Blocked origins7http://evil.com, https://evil.com, null, empty string, custom schemes
Edge cases4Case sensitivity, trailing slashes, substring spoofing

The edge case tests are important. A prefix check on chrome-extension:// must not match chrome-extension-evil://abc. The startsWith() approach handles this correctly because the :// suffix in the prefix string prevents substring collisions, but the tests document the assumption explicitly.

A pattern, again

This is the same vulnerability class I found in the summarize project's localhost daemon, though the mechanism differs. Summarize echoed arbitrary origins in CORS headers. AIPex skipped origin validation on WebSocket upgrades entirely. Both daemons bound to localhost as if that were sufficient protection. Both served browser extensions that needed cross-origin access. Both defaulted to permissive when they should have defaulted to restrictive.

The MCP ecosystem is producing a growing fleet of localhost daemons that bridge AI tools to browser extensions and local services. Each one faces the same design tension: the legitimate consumer is a browser extension that needs cross-origin access, but the path of least resistance is granting that access to everyone. The ws library's own documentation recommends origin validation when using { noServer: true }. The recommendation exists because the default is unsafe and the developers building on top of it routinely miss the implication.

Localhost is not a security boundary. It is a network address. The browser is already there.

Newsletter

One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.