GHSA-6G2F-W7G3-77VF
Vulnerability from github – Published: 2026-07-02 21:13 – Updated: 2026-07-02 21:13Summary
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.
Root Cause
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.
Attack Scenario
Scenario 1: Cloudflare Tunnel / Tailscale Funnel
9router natively supports Cloudflare Tunnel and Tailscale (see LOCAL_ONLY_PATHS entries for /api/tunnel/*). When exposed via tunnel:
- Attacker sends request to
https://<tunnel-domain>/api/mcp/<plugin>/sse - Sets
Host: localhost:3000andOrigin: http://localhost:3000 isLocalRequest()returnstruecanAccessLocalOnlyRoute()then requires CLI token or (local + JWT)- CLI token is
getConsistentMachineId("9r-cli-auth")— a deterministic HMAC of the machine's hardware/OS identifiers
Scenario 2: DNS Rebinding
- Attacker controls
evil.comDNS, initially resolving to attacker IP - Victim's browser navigates to
evil.com(or via iframe/redirect) - DNS rebinding switches
evil.com→127.0.0.1 - Subsequent fetch to
evil.com:3000/api/mcp/<plugin>/messagereaches 9router Hostheader isevil.com:3000— this is blocked by the current check (not in LOOPBACK_HOSTS)- However, if the attacker uses
localhost:3000as the request host via CORS or service worker tricks, and the browser sendsHost: localhost:3000, the gate opens
Exploitation (when CLI token is obtained)
Once past the gate, the attacker can:
GET /api/mcp/<plugin>/sse— establish SSE session, getsessionIdPOST /api/mcp/<plugin>/message— send arbitrary JSON-RPC to the child process stdin- The child process is one of:
npx,node,python,python3,uvx,bunx,bun - Depending on the MCP plugin implementation, this can achieve arbitrary code execution on the host
Steps to Reproduce
- Deploy 9router behind a reverse proxy or tunnel
- From a remote host, send:
GET /api/mcp/browser/sse HTTP/1.1
Host: localhost:3000
Origin: http://localhost:3000
x-9r-cli-token: <machine-id-derived-token>
- Observe: SSE connection established,
endpointevent received with message URL - POST arbitrary JSON-RPC to the message endpoint
Impact
An 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)
Remediation
-
Check actual source IP, not headers. Use
request.ip,request.socket.remoteAddress, or a trustedX-Forwarded-Forheader with known proxy configuration instead ofHost/Originfor 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.1only, not rely on middleware header checks.
Credit: @snailsploit
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "9router"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.4.55"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49353"
],
"database_specific": {
"cwe_ids": [
"CWE-290"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-02T21:13:19Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe 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 \u2014 both natively supported), or is subject to DNS rebinding, these headers are attacker-controlled, allowing the local-only gate to be bypassed.\n\nA 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.\n\nThis is a variant / incomplete fix of CVE-2026-46339 \u2014 the same attack surface (remote \u2192 MCP child process stdin) remains reachable under specific but realistic deployment configurations.\n\n## Root Cause\n\n`isLocalRequest()` at `src/dashboardGuard.js:93-101`:\n\n```javascript\nfunction isLocalRequest(request) {\n if (!isLoopbackHostname(request.headers.get(\"host\"))) return false;\n const origin = request.headers.get(\"origin\");\n if (origin) {\n try {\n if (!isLoopbackHostname(new URL(origin).hostname)) return false;\n } catch { return false; }\n }\n return true;\n}\n```\n\nThis 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.\n\n## Attack Scenario\n\n### Scenario 1: Cloudflare Tunnel / Tailscale Funnel\n\n9router natively supports Cloudflare Tunnel and Tailscale (see `LOCAL_ONLY_PATHS` entries for `/api/tunnel/*`). When exposed via tunnel:\n\n1. Attacker sends request to `https://\u003ctunnel-domain\u003e/api/mcp/\u003cplugin\u003e/sse`\n2. Sets `Host: localhost:3000` and `Origin: http://localhost:3000`\n3. `isLocalRequest()` returns `true`\n4. `canAccessLocalOnlyRoute()` then requires CLI token or (local + JWT)\n5. CLI token is `getConsistentMachineId(\"9r-cli-auth\")` \u2014 a deterministic HMAC of the machine\u0027s hardware/OS identifiers\n\n### Scenario 2: DNS Rebinding\n\n1. Attacker controls `evil.com` DNS, initially resolving to attacker IP\n2. Victim\u0027s browser navigates to `evil.com` (or via iframe/redirect)\n3. DNS rebinding switches `evil.com` \u2192 `127.0.0.1`\n4. Subsequent fetch to `evil.com:3000/api/mcp/\u003cplugin\u003e/message` reaches 9router\n5. `Host` header is `evil.com:3000` \u2014 this is **blocked** by the current check (not in LOOPBACK_HOSTS)\n6. However, if the attacker uses `localhost:3000` as the request host via CORS or service worker tricks, and the browser sends `Host: localhost:3000`, the gate opens\n\n### Exploitation (when CLI token is obtained)\n\nOnce past the gate, the attacker can:\n\n1. `GET /api/mcp/\u003cplugin\u003e/sse` \u2014 establish SSE session, get `sessionId`\n2. `POST /api/mcp/\u003cplugin\u003e/message` \u2014 send arbitrary JSON-RPC to the child process stdin\n3. The child process is one of: `npx`, `node`, `python`, `python3`, `uvx`, `bunx`, `bun`\n4. Depending on the MCP plugin implementation, this can achieve arbitrary code execution on the host\n\n## Steps to Reproduce\n\n1. Deploy 9router behind a reverse proxy or tunnel\n2. From a remote host, send:\n\n```http\nGET /api/mcp/browser/sse HTTP/1.1\nHost: localhost:3000\nOrigin: http://localhost:3000\nx-9r-cli-token: \u003cmachine-id-derived-token\u003e\n```\n\n3. Observe: SSE connection established, `endpoint` event received with message URL\n4. POST arbitrary JSON-RPC to the message endpoint\n\n## Impact\n\nAn 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.\n\nThe severity is reduced from CVE-2026-46339\u0027s CVSS 10.0 because:\n- Requires proxied/tunneled deployment (not default localhost-only)\n- Requires obtaining the CLI token (deterministic but not trivially guessable without another primitive)\n\n## Remediation\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n\nCredit: @snailsploit",
"id": "GHSA-6g2f-w7g3-77vf",
"modified": "2026-07-02T21:13:19Z",
"published": "2026-07-02T21:13:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/decolua/9router/security/advisories/GHSA-6g2f-w7g3-77vf"
},
{
"type": "WEB",
"url": "https://github.com/decolua/9router/commit/5e1c1261368e06dced1cbc650684561b2c8844db"
},
{
"type": "WEB",
"url": "https://github.com/decolua/9router/commit/bb86808582067e4fc6f004508a919efb9970d1d5"
},
{
"type": "PACKAGE",
"url": "https://github.com/decolua/9router"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "9router has an Incomplete Fix: Local-Only Access Gate Bypass in 9router via Host Header SpoofING"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.