The fix for CVE-2026-46339 (unauthenticated RCE via unprotected MCP plugin routes) introduced a local-only access gate in src/dashboardGuard.js that restricts spawn-capable routes (/api/mcp/*, /api/tunnel/*, /api/cli-tools/*) to loopback requests. The gate determines "local" by inspecting the Host and Origin HTTP headers rather than the TCP source address. When 9router is deployed behind a reverse proxy, tunnel (Cloudflare Tunnel, Tailscale — both natively supported), or is subject to DNS rebinding, these headers are attacker-controlled, allowing the local-only gate to be bypassed.
A second factor (CLI token or JWT cookie) is required by canAccessLocalOnlyRoute(), but the CLI token is a deterministic HMAC of the machine ID (getConsistentMachineId), which is stable and predictable on cloud VMs. If the attacker can obtain or guess the machine ID (e.g., via another information disclosure, or on shared-tenant infrastructure), the full chain to MCP child process stdin injection is reachable.
This is a variant / incomplete fix of CVE-2026-46339 — the same attack surface (remote → MCP child process stdin) remains reachable under specific but realistic deployment configurations.
isLocalRequest() at src/dashboardGuard.js:93-101:
function isLocalRequest(request) {
if (!isLoopbackHostname(request.headers.get("host"))) return false;
const origin = request.headers.get("origin");
if (origin) {
try {
if (!isLoopbackHostname(new URL(origin).hostname)) return false;
} catch { return false; }
}
return true;
}
This function trusts Host and Origin headers as proof of local origin. Both are attacker-controlled in any proxied deployment. The LOOPBACK_HOSTS set (localhost, 127.0.0.1, ::1) is checked against these headers, not against the actual connection source IP.
9router natively supports Cloudflare Tunnel and Tailscale (see LOCAL_ONLY_PATHS entries for /api/tunnel/*). When exposed via tunnel:
https://<tunnel-domain>/api/mcp/<plugin>/sseHost: localhost:3000 and Origin: http://localhost:3000isLocalRequest() returns truecanAccessLocalOnlyRoute() then requires CLI token or (local + JWT)getConsistentMachineId("9r-cli-auth") — a deterministic HMAC of the machine's hardware/OS identifiersevil.com DNS, initially resolving to attacker IPevil.com (or via iframe/redirect)evil.com → 127.0.0.1evil.com:3000/api/mcp/<plugin>/message reaches 9routerHost header is evil.com:3000 — this is blocked by the current check (not in LOOPBACK_HOSTS)localhost:3000 as the request host via CORS or service worker tricks, and the browser sends Host: localhost:3000, the gate opensOnce past the gate, the attacker can:
GET /api/mcp/<plugin>/sse — establish SSE session, get sessionIdPOST /api/mcp/<plugin>/message — send arbitrary JSON-RPC to the child process stdinnpx, node, python, python3, uvx, bunx, bunGET /api/mcp/browser/sse HTTP/1.1
Host: localhost:3000
Origin: http://localhost:3000
x-9r-cli-token: <machine-id-derived-token>
endpoint event received with message URLAn attacker who can reach a proxied/tunneled 9router instance and obtain the deterministic CLI token can bypass the local-only restriction and interact with MCP child processes (node, python, npx, etc.) via stdin. This achieves the same impact as CVE-2026-46339: remote code execution on the host.
The severity is reduced from CVE-2026-46339's CVSS 10.0 because: - Requires proxied/tunneled deployment (not default localhost-only) - Requires obtaining the CLI token (deterministic but not trivially guessable without another primitive)
Check actual source IP, not headers. Use request.ip, request.socket.remoteAddress, or a trusted X-Forwarded-For header with known proxy configuration instead of Host/Origin for the local-only gate.
Make CLI token non-deterministic. Generate a random token on first run and persist it, rather than deriving from machine ID. Machine IDs are often predictable or discoverable on cloud infrastructure.
Bind MCP routes to loopback at the network layer. If MCP is local-only by design, the server should bind those routes to 127.0.0.1 only, not rely on middleware header checks.
Credit: @snailsploit
{
"github_reviewed_at": "2026-07-02T21:13:19Z",
"nvd_published_at": null,
"github_reviewed": true,
"cwe_ids": [
"CWE-290"
],
"severity": "HIGH"
}