GHSA-x5c9-v98j-722r: High severity npm/9router vulnerability
Summary
9router treats local loopback requests as trusted and allows access to /v1/ without an API key. In a documented/common reverse-proxy deployment where nginx forwards public traffic to the backend via 127.0.0.1, external non-Origin requests are misclassified as local. This allows unauthenticated access to /v1 APIs such as /v1/models, and may allow abuse of configured upstream provider credentials depending on the enabled providers.
Details
- Affected version / commit: 9router v0.4.80 @ b282f05. - Deployment precondition: a same-host reverse proxy (e.g. nginx) forwarding public traffic to the backend on 127.0.0.1 / localhost. This mirrors the documented cloud deployment (proxypass http://localhost:20128 with X-Real-IP / X-Forwarded-For). - Observed behaviour: - The direct backend (direct-backend, port 18081) returns 401 for /v1/models without an API key. - A direct request that spoofs X-9r-Real-IP: 127.0.0.1 still returns 401: the custom server deletes the client-supplied header and overwrites it with the real socket address, so naive header spoofing does not work against the direct backend. - The proxied path (reverse-proxy, port 18080) returns 200 with the full model catalog for the same /v1/models request without any API key. - A proxied request that carries an Origin header returns 401. The bypass therefore primarily affects curl / SDK / server-side / non-browser clients, which do not send Origin. - Root cause: the backend's local/remote decision relies on perceived socket/loopback locality after reverse proxying. Because nginx connects to the backend from 127.0.0.1, the backend stamps a loopback client address for every internet client and treats the request as local, skipping the /v1 API-key requirement. The forwarded X-Real-IP / X-Forwarded-For headers that carry the true client IP are ignored for this decision. - This is not a simple client header-spoofing issue (the direct-spoof control above proves header spoofing is rejected); it is a property of how loopback proxy traffic is trusted.
Proof of Concept
This repository is a self-contained Docker Compose reproduction. No real provider is called and no real API key is required.
1. Build and start the stack: bash docker compose up --build 2. Direct baseline (no API key): bash curl -i http://127.0.0.1:18081/v1/models 3. Direct spoof control: bash curl -i -H "X-9r-Real-IP: 127.0.0.1" http://127.0.0.1:18081/v1/models 4. Reverse-proxy bypass (no API key): bash curl -i http://127.0.0.1:18080/v1/models 5. Reverse-proxy Origin control: bash curl -i -H "Origin: http://evil.example" http://127.0.0.1:18080/v1/models
Expected evidence
| Request | Result | |---------|--------| | Direct 18081, no key | 401 Unauthorized ({"error":"API key required for remote API access"}) | | Direct 18081, X-9r-Real-IP: 127.0.0.1 spoof | 401 Unauthorized | | Proxied 18080, no key | 200 OK with the full model catalog | | Proxied 18080, with Origin | 401 Unauthorized |
Impact
- Unauthenticated access to the /v1 API surface in the affected reverse-proxy deployment. - Model enumeration via /v1/models. - Possible abuse of the operator's configured upstream provider credentials through /v1/chat/completions and other /v1 proxy endpoints (the attacker spends the operator's provider quota/keys without holding any key of their own). - Actual impact depends on which providers are configured and how the instance is exposed to the public internet. - The attacker requires no API key.
Suggested Fix
- Do not use client/proxy/socket IP locality as an authentication bypass. - Require an API key by default for /v1/ on public listeners. - If local trust is genuinely needed, bind it to an unguessable server-generated secret or to a Unix domain socket that is only accessible locally — not to "the connection looks like loopback". - When running behind reverse proxies, use an explicit trusted-proxy configuration and a real client-IP derivation (e.g. a vetted X-Forwarded-For chain), and never treat all loopback proxy traffic as end-user-local. - Document a secure reverse-proxy configuration for operators.
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
Require an API key by default for all /v1/* endpoints exposed on public listeners.
9router API-key requirement for /v1/* on public listeners = required by default - Configuration
Configure an explicit trusted-proxy policy and derive the real client IP from a vetted X-Forwarded-For chain; do not treat all loopback proxy traffic as end-user-local or use client/proxy/socket IP locality as an authentication bypass.
9router reverse-proxy deployment trusted-proxy configuration and client-IP derivation = explicit trusted proxy with a vetted X-Forwarded-For chain - Compensating control
If local trust is genuinely needed, bind it to an unguessable server-generated secret or expose it through a Unix domain socket accessible only locally, rather than trusting connections solely because they appear to originate from loopback.
Event History
Frequently Asked Questions
Which deployments are exposed?
Deployments with a same-host reverse proxy, such as nginx, that forwards public traffic to 9router through 127.0.0.1 or localhost are exposed. This includes the documented cloud deployment pattern using proxy_pass http://localhost:20128 with X-Real-IP and X-Forwarded-For.
Does an attacker need an API key or a spoofed client-IP header?
No API key is required for affected requests reaching the backend through the vulnerable proxied path. Simply sending X-9r-Real-IP: 127.0.0.1 directly to the backend does not work, because 9router replaces that client-supplied header with the real socket address; the issue depends on the reverse-proxy path.
What can an unauthenticated requester access?
Unauthenticated access to /v1 APIs, including /v1/models, is possible through the affected deployment path. Depending on enabled providers, this may also permit abuse of upstream provider credentials configured in 9router.