9router unauthenticated LLM-proxy access: Host-header spoofing and /codex rewrite bypass — operator validation¶
Date reviewed: 2026-08-28 Advisories: GHSA-86m2-fcxq-5q7c / CVE-2026-55641 (high), GHSA-8gmq-j984-vp4r / CVE-2026-55638 (high). Both in npm/9router <= 0.4.80 (fixed in 0.5.2).
9router is an OpenAI/Anthropic-compatible LLM proxy that fronts the operator's stored paid provider API keys. Both findings bypass its auth gate, letting an unauthenticated remote attacker use the victim's provider credentials — and, in the first case, drive a server-side fetch. The durable pattern: the authorization decision is made on a client-controlled field (Host header) or on the pre-rewrite request path, and the "local/trusted" exemption or the rewrite destination is not re-validated after the decision.
1. Unauthenticated /v1 proxy access via Host-header spoofing (GHSA-86m2-fcxq-5q7c)¶
9router's request guard (src/dashboardGuard.js, isLocalRequest) 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 sends Host: localhost and is treated as local. 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: src/dashboardGuard.js (isLocalRequest), src/sse/handlers/{chat,search}.js, src/lib/db/repos/settingsRepo.js, cli/cli.js. Distinct from the already-patched GHSA-fhh6-4qxv-rpqj (MCP-plugin RCE) and GHSA-xrrh-p7f2-27vm (legacy <0.3.75 authz bypass).
2. Unauthenticated LLM proxy access via /codex rewrite auth bypass (GHSA-8gmq-j984-vp4r)¶
The API-key gate lives in the Next.js middleware and protects /v1, /v1beta, /api/v1, and /api/v1beta — but not /codex. 9router also defines a rewrite that maps /codex/* to the backend LLM endpoint /api/v1/responses. The middleware authorization decision is made on the incoming request path before the rewrite is applied. Because /codex is not in the middleware's protected LLM API prefix list, requests to /codex/* pass the gate unauthenticated and are later rewritten to the same backend used by /api/v1/responses:
| Component | File | Note |
|---|---|---|
| Middleware authorization gate | src/dashboardGuard.js | Protects /v1, /v1beta, /api/v1, /api/v1beta, but not /codex |
| Rewrite configuration | next.config.mjs | Rewrites /codex/:path* to /api/v1/responses |
| LLM backend route | src/app/api/v1/responses/route.js | Dispatches rewritten requests to the LLM handler |
Result: an unauthenticated remote attacker reaches the LLM proxy through /codex/* and causes the server to make upstream provider calls using the operator-stored LLM provider credentials.
Durable operator value¶
- A "local" exemption keyed off the
Hostheader is an auth bypass. Any middleware that treats loopback/localhostHostvalues as trusted — on a service that binds0.0.0.0— lets any remote peer self-declare as local. The fix has to decide locality from the socket peer, not a header. Probe: sendHost: localhost(and127.0.0.1,[::1]) to an externally-reachable port and compare the auth decision. - Auth-on-pre-rewrite path = bypass. When an edge/framework rewrites a public path to an internal API and the auth gate runs on the original path, any public alias that maps onto a protected internal route is open. Enumerate the rewrite/alias table (
next.config.mjsrewrites, nginxlocation→proxy_pass, TraefikStripPrefix/route aliases) and diff it against the auth-protected prefix list. Every rewrite destination not covered by the gate is a candidate. - Default-no-key + default-0.0.0.0 = open relay. A proxy that fronts paid credentials and defaults to "no API key required" plus "bind all interfaces" is an open relay out of the box. The
DEFAULT_SETTINGSmissingrequireApiKeyis the concrete instance; the heuristic is "does the default config require a key on the proxy path?" - Reflecting the provider's response is an SSRF amplifier. When the proxy reflects the upstream provider JSON back to the caller, a
provider_options.baseUrl-driven fetch becomes a read-SSRF against internal/cloud-metadata. Probe with a benign internal URL and record the reflected response shape (don't target real metadata in production).
Replayable validation (lab only)¶
Preconditions: an authorized lab 9router <= 0.4.80 (or a patched 0.5.2 control), a disposable upstream provider key stored in the proxy, a lab internal callback host, and a redacting recorder. No production provider keys, no real metadata/internal services, no cost-generating provider calls beyond a single canary.
- Host-spoof bypass (GHSA-86m2-fcxq-5q7c). From a non-loopback host, send
GET /v1/...(or a minimal chat request) withHost: localhostand no API key. Positive: the proxy accepts it and makes an upstream call using the stored key. Negative control: the same request from loopback with the key, and the same request with a non-loopbackHost(which should 401). Record the guard decision (accepted vs 401) and the upstream call (provider + redacted key presence), not the key value. - SSRF leg (GHSA-86m2-fcxq-5q7c). With the bypass active,
POST /v1/searchwith thesearxngprovider andprovider_options.baseUrlset to a lab callback. Positive: the lab callback receives the server-side fetch. Do not point it at cloud metadata or internal services in production. - /codex rewrite bypass (GHSA-8gmq-j984-vp4r). Send an unauthenticated request to
/codex/*that maps to/api/v1/responses. Positive: the LLM handler executes an upstream call with the operator's key. Negative control: the identical request to/api/v1/responsesdirectly (should 401) and the same/codex/*path on0.5.2(should 401 or be rewritten behind the gate). - Default-posture check. Confirm
requireApiKeyis absent fromDEFAULT_SETTINGSand the listener binds0.0.0.0(the CLI prints "localhost" but binds all). Report the default config, not just the exploit.
Evidence to capture: the guard/rewrite source lines, the Host/path that flipped the auth decision, the upstream provider call with the redacted key, and the reflected/forwarded response shape. Redact all provider keys.
Safe boundaries¶
- Authorized lab 9router only; disposable upstream key; lab callback host.
- No production provider keys, no cloud-metadata or internal-service SSRF, no cost-generating provider calls beyond a single canary, no credential exfiltration.
- Report the exact guard/rewrite line, the default no-key/all-interfaces posture, and the pre- vs post-rewrite (or spoofed-Host) differential with the patched-version negative control.
September 22 follow-up: the same product, now spoofing the IP header (GHSA-5mj8-gf6m-fhw8 / CVE-2026-56681 high, GHSA-32gc-64m7-hj7v / CVE-2026-56682 medium)¶
9router 0.5.4 shipped two more findings that are the header-spoofing lesson retargeted from Host to a custom forwarding header, X-9r-Real-Ip:
- Unauthenticated LLM API via spoofable
X-9r-Real-Ip(CVE-2026-56681):isLocalRequest()insrc/dashboardGuard.jsreadsX-9r-Real-Ipto decide loopback origin;canAccessPublicLlmApi()skips API-key validation on/api/v1/*when "local". The header is only stripped/regenerated by the bundledcustom-server.jswrapper — in deployment modes where traffic reaches Next.js directly,X-9r-Real-Ip: 127.0.0.1from any remote peer = unauthenticated use of the operator's stored provider keys (verified onGET /api/v1/models). - Login lockout bypass via the same header (CVE-2026-56682): the progressive login lockout is keyed on
getClientIp()which trustsX-9r-Real-Ip. Rotating a unique header value per request gives each attempt a fresh limiter bucket — reproduced live: fixed header locked out (429) after 5 attempts, rotating header produced unlimited 401s.
Durable additions to the sweep rules above:
- Enumerate the custom forwarding headers, not just the standards.
X-Forwarded-ForandX-Real-IPget audited;X-9r-Real-Ip-style vendor-branded headers do not. Grep the app source for header reads feeding an authn/authz/limiter decision (getHeader('x-...ip'),req.headers[...]nearisLocal/getClientIp/limiter), then test whether the edge layer that should sanitize the header is actually in the request path for the deployment mode you're looking at. "Trusted header produced by wrapper X" is only a control if wrapper X is always deployed — self-hosted apps commonly have a direct-to-framework path that skips it. - Rate-limit identity is an authorization decision. A limiter keyed on a spoofable field is a lockout bypass, full stop. Negative-control protocol from this pair: fixed spoofed value → does the limiter ever trip? rotating spoofed value → does it reset? The rotation differential is the proof. This generalizes across every product family on this wiki that keys lockouts on forwarded IPs (the Airflow/Sync-in/MISP lockout-drift pages all test counter state advance; here also test identity stability).
- Same guard, third bug class. August's findings keyed locality on
Host; September's key it on a custom IP header. Once you find one header-trusted locality decision indashboardGuard.js, treat every field that guard consumes as a candidate — one confirmed spoofable input in a security decision is a confession about the module, not a single bug.
Replayable validation stays lab-only: a direct-to-Next.js 9router lab instance, X-9r-Real-Ip: 127.0.0.1 canary request to /api/v1/models (positive) vs same request without the header (401 negative control), and lockout-bucket rotation with your own synthetic credentials; never against shared instances (rotating-header guessing locks nothing but still hammers real login endpoints).
September 23 follow-up: fourth and fifth bug classes on the same guard — proxy locality collapse, settings mass assignment, rebinding prefetch, and template-host credential forwarding (CVE-2026-56675/56676/56678/56679)¶
The August lesson ("one confirmed spoofable input in a security decision is a confession about the module") played out exactly as predicted: a four-advisory wave on 9router (published 2026-09-23T18:12Z, v0.4.80–v0.5.2 affected) adds two unauthenticated and two authenticated primitives, each with a reusable axis.
- Unauthenticated
/v1/*via reverse-proxy locality collapse (GHSA-x5c9-v98j-722r / CVE-2026-56675, high): the header-spoofing fix works — the bundled custom server deletes client-suppliedX-9r-Real-IPand overwrites it from the socket, and a direct spoof control 401s. But in the documented nginx deployment (proxy_pass http://localhost:20128), the backend's loopback decision now reads the socket peer, which is nginx at127.0.0.1— every internet client is stamped local, and the forwardedX-Real-IP/X-Forwarded-Forcarrying the true client IP are ignored for that decision./v1/modelsreturns the full catalog with no API key through the proxy path while the direct backend 401s. Extra twist: a proxied request carrying anOriginheader 401s — the bypass only fires for curl/SDK/server-side (non-browser) clients, so browser-driven probing will false-negative. Durable axes: (1) fixing header trust by switching to socket peer is not a fix behind a same-host loopback proxy — locality claims need the original client address resolved through trusted proxy chain semantics, not "who connected to me"; (2) test both entry points — direct-to-backend and through-proxy can give opposite auth verdicts on the same route; the proxied path is the one real users traverse; (3) presence/absence ofOriginis itself an auth-relevant differential — replay every authz probe with and without browser headers. - Image-prefetch DNS rebinding SSRF (GHSA-cmhj-wh2f-9cgx / CVE-2026-56676, high): the guard resolves the image URL host and rejects private IPs, then the later server-side prefetch resolves the name again — a TTL-0 rebinding authority returns a public IP to the guard and an internal Docker-network IP to the fetch. Reachable through
/v1/chat/completionswith animage_urlpart, gated on a vision-capable model name (non-vision models strip the image part before the prefetch arms). Axes: DNS-rebinding TOCTOU generalizes to any "resolve-then-fetch" SSRF guard — pair a TTL-0 rebinding server with a public→internal A-record flip as a standard guard-bypass probe; and feature-gating is not authz but is a reachability precondition — enumerate the model/feature flags a payload needs before the sink arms (the modality-stripper silently discarding your SSRF payload looks like "not vulnerable" unless you pick a vision model). - Mass assignment in
PATCH /api/settings→ global auth disable (GHSA-vmjq-hvgq-2wv4 / CVE-2026-56679, high): the handler passes the whole body intoupdateSettings({...current, ...updates}), so an authenticated low-privilege session can setrequireLogin:false— the dashboard guard then returns "authenticated" for every request and/api/keys(all stored provider keys) reads unauthenticated. Chained with the default password +tunnelDashboardAccessdefault-true = remote full compromise. Axis: sweep settings/PUT-PATCH endpoints for security-bearing keys (requireLogin,authMode,*Access) — every settings writer that spreads the request body is a post-auth auth-off switch; the restore-yourself-then-verify-401 loop in the PoC is the polite lab protocol. - Template-host SSRF with credential forwarding via fragment terminator (GHSA-6mwv-4mrm-5p3m / CVE-2026-56678, medium):
regioninterpolates intohttps://codewhisperer.${region}.amazonaws.com;region: "kiro-canary.local:8443#"turns the trusted suffix into a URL fragment, relocating the request to the attacker host — which receives the bearer token asAuthorizationand whose response body is reflected back on error. The older@host#userinfo payload is dead against Node/undici (URLs with embedded credentials are rejected at construction), so the#fragment terminator is the live spelling of "trusted-suffix template escape" for JS runtimes. Axis: for every`https://prefix.${userInput}.suffix.tld`template, testhost#,host:port#, and (on non-undici stacks)x@host#; capture proof at an owned callback — the finding is the forwarded Authorization header landing on your host, not just reaching it.
Same guard, now five bug classes across two months (Host locality, custom-IP-header locality, proxy-collapse locality, settings mass assignment, prefetch TOCTOU): when one module has produced two authz advisories, treat every input it consumes — and its documented deployment topologies — as an open audit queue.
Sources¶
- GitHub Advisory Database: 9router GHSA-86m2-fcxq-5q7c / CVE-2026-55641
- GitHub Advisory Database: 9router GHSA-8gmq-j984-vp4r / CVE-2026-55638
- Related (already patched): GHSA-fhh6-4qxv-rpqj MCP-plugin RCE, GHSA-xrrh-p7f2-27vm legacy authz bypass
- September 22 fold: GHSA-5mj8-gf6m-fhw8 / CVE-2026-56681, GHSA-32gc-64m7-hj7v / CVE-2026-56682
September 22 evening follow-up: Fabio hop-by-hop stripping still reaches the operator-configured trust headers (incomplete fix, CVE-2026-62987)¶
- fabiolb/fabio 1.6.6–1.7.1 (GHSA-fq95-v8xc-jm3v, fixed 1.7.2): the v1.6.6 fix for CVE-2025-48865 sweeps the client
Connectionheader against a hardcodedprotectHeadersallowlist containing only the 7 X-Forwarded-family names — but Fabio also injects three operator-configured trust headers (proxy.header.clientip,proxy.header.tls,proxy.header.requestid) that aren't in that list. A client naming them inConnection:makes Go'sReverseProxy.removeHopByHopHeadersdelete the router's own trusted values before the backend sees them — client-IP spoofing defense stripped, TLS-header signal erased, request-ID correlation removed, all by a compliant RFC-7230 header. Same page's lesson, upstream edition: the forwarding-header audit must include every configured header name the proxy sets, not just the XFF family — an allowlist that enumerates standards names but not operator-configured names re-opens the exact hole it was written to close. Test shape (lab proxy + echo backend): request withConnection: X-Forwarded-For, <configured-clientip-header>and diff which trusted headers arrive; the absence of a header the proxy definitely set (verify via a no-Connectioncontrol) is the finding. Sweep target: any reverse proxy/LB with configurable header-injection options (Fabio, Traefik, Envoyset_current_client_cert_detailssiblings, nginxproxy_set_headerbehind another proxy).
October 1 follow-up: sixth bug class — caller-supplied provider_options.baseUrl escapes the SSRF guard module itself (CVE-2026-103530)¶
- 9Router ≤0.5.55 SSRF via
provider_options.baseUrlon the search endpoint (GHSA-ggrj-5337-qvwm / CVE-2026-103530, fixed by PR #3723): the request-URL leg now goes throughsrc/shared/utils/ssrfGuard.js, but the search endpoint accepts a caller-controlled provider configuration object whosebaseUrlis used for a subsequent fetch — the guard never sees that second destination. Sixth distinct bug class on this one module family in two months (Host locality, custom-IP-header locality, proxy-collapse locality, settings mass assignment, prefetch TOCTOU, and now guard-bypass-via-second-fetch-destination). - Durable axis, sharpening rule 3 from August: when a "security" module has produced multiple advisories, the productive sweep is not the guarded call site but every other network egress in the same feature that doesn't route through it. In LLM-gateway/proxy products specifically, provider/baseURL configuration is fetch destination: any endpoint that accepts provider options (
baseUrl,api_base,endpoint) in the request body — not just admin settings — turns config mass-assignment into SSRF. Probe authenticated search/chat/completions endpoints with aprovider_options.baseUrl(or sibling key) pointed at your owned callback even when the main URL field is guarded; the finding is which fetch legs the guard library is not wired into.