9router: Unauthenticated `/v1` proxy access via `Host`-header spoofing → open AI relay + SSRF
Summary
9router's request guard decides a request is "local" (and therefore exempt from API-key auth on the /v1 LLM proxy) by reading the client-controlled Host header. Because 9router binds 0.0.0.0 by default (and the CLI misleadingly prints "localhost"), a remote, unauthenticated attacker who can reach the port can send Host: localhost to be treated as local and obtain /v1 proxy access with no API key, no CLI token, and no dashboard login. In the default configuration (requireApiKey is absent from DEFAULT_SETTINGS, so the handler-side key check is skipped), this yields:
-
Open AI relay — the proxy forwards the attacker's requests to AI providers using the victim's stored paid API keys (cost/quota theft, prompt-based data exfiltration through the victim's accounts).
-
Unauthenticated SSRF —
/v1/searchwith the built-innoAuthsearxngprovider takes its outbound fetch URL from the request body (provider_options.baseUrl), so the attacker drives a server-side fetch to any internal/cloud-metadata host and gets the JSON response reflected back. -
Affected: `9router the 9router SERVER PROCESS issued a GET to the attacker-controlled URL = unauthenticated SSRF.
(B') relay path POST /v1/messages, same Host-spoof: honest Host => 401 ; Host: localhost => 404 {"error":"No active credentials for provider: openai"} => bypass reached handleChat's provider selection (would forward on the VICTIM'S key if one were configured).
Changing **only** the `Host` header (401 → reaches the handler), from the same remote peer, is the entire bypass — confirmed live on a default-config running instance. (Full SSRF response *reflection* requires the upstream to return searxng-shaped JSON; otherwise it is a blind/semi-blind SSRF — the server-side request to the attacker URL is the proven primitive. The relay needs ≥1 configured provider — the normal state — to actually spend the victim's key.) See `repro/LIVE-EVIDENCE.txt`.
**Reproduce** (against a network-reachable 9router; `VICTIM_IP` = the box):
```bash
# Open AI relay — no Authorization/x-api-key/cookie; victim's key pays:
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"}]}'
# SSRF — attacker-controlled server-side fetch (e.g. cloud metadata), JSON reflected:
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"}}'
Impact
Any 9router reachable on a network (default 0.0.0.0 bind, plus Docker -p, tunnel, or tailscale — all first-class features) can be:
- used as a free AI relay billed to the victim's provider accounts, exhausting quota and exfiltrating data through their keys; and
- used to reach internal services / cloud metadata (
169.254.169.254) with the response reflected to the attacker. Unauthenticated, no user interaction, default configuration. The only precondition is the normal one (≥1 configured provider).
Recommended fix
- Determine "local" from the socket peer IP, never the
Hostheader — treat as local only if the TCP peer is127.0.0.0/8/::1. - Bind
127.0.0.1by default; require an explicit, warned opt-in for0.0.0.0; fix the CLI to not print "localhost" when bound to all interfaces. - For any non-loopback peer, require a valid API key regardless of
requireApiKey; addrequireApiKey: truetoDEFAULT_SETTINGS(fail-closed). - Validate
provider_options.baseUrlagainst an allowlist (or drop the override) and block requests to private/link-local ranges inresolveBaseUrl. - Remove
Access-Control-Allow-Origin: *from/v1GET metadata routes.