Skip to main content
kRouter
All posts
How kRouter works

How a gateway leaks the keys it holds

Four ways an LLM gateway hands out the keys it stores, the October 2026 LiteLLM CVEs behind each one, and the kRouter releases that fixed the same shapes.

Kodelyth · The team behind kRouter
· Updated
8 min read

One sentence from Check Point Research's 13 October 2026 write-up on LiteLLM explains why a gateway is a different kind of target from the services behind it:

The attacker's server receives the full request, including any configured API keys in the proxy's default headers.

That is CVE-2026-59823. A caller sends a request body naming the host it wants the proxy to talk to, and the proxy obliges — attaching, as it always does, the provider credential it stores on your behalf. Not a data leak, not quota theft. The key itself, delivered.

This post is about the four shapes that produce that outcome. All four are generic: any gateway with an admin endpoint, a route allowlist, a caller-supplied URL or a published quickstart credential has somewhere to put each one. kRouter has shipped three of the four and fixed them, with version numbers, and those are in here too.

What a gateway is actually holding

A metered API key is replaceable: notice the spend, rotate it in a dashboard, move on. A gateway that fronts subscription tools holds something worse — OAuth tokens minted by a sign-in flow for Claude Code, Codex, Copilot, Antigravity and Kiro. There is no rotate button for those. You re-authenticate, you may trip a device-trust check, and in the worst case the provider flags the account.

As of 19 October 2026 there are three BerriAI LiteLLM entries in CISA's Known Exploited Vulnerabilities catalog, added 8 May, 8 June and 2 September 2026. KEV listing means observed exploitation, not theory; Wiz's September report says its honeypots first saw the MCP authentication bypass used on 7 July 2026. None of the three is there for a clever cryptographic break. All three are ordinary web-application bugs in software that happens to hold everyone's keys.

Shape 1: a route allowed by its prefix

LiteLLM's POST /config/update checked that the caller was authenticated but not that they were an admin. The route classifier used a startswith test against a list that contained /config, so /config/update matched and any ordinary dashboard session token slid through. The GitHub advisory for CVE-2026-35029 lists the consequences: modify configuration and environment variables, register pass-through handlers pointing at attacker-controlled Python, read arbitrary files, and overwrite the dashboard username and password.

kRouter shipped the same class of bug in the opposite direction.

Two stacked panels: LiteLLM's config update endpoint matching a prefix allowlist with no admin check, and kRouter's codex path matching no entry in its prefix list and falling through with no authentication
One prefix matched too much, one was missing entirely. The class is the same.

kRouter's Next config rewrites /codex/:path* onto its Responses endpoint, but /codex was absent from the guard's list of public prefixes. The guard runs before rewrites, so it saw a literal path it had no branch for — not the public-API branch, not the deny-by-default branch for /api/*, not the dashboard branch — and let it through unauthenticated. From off the machine with no credentials, POST /v1/responses returned 401 while POST /codex/responses returned a real streaming completion. Being absent from the allowlist is what broke it, because that list is what routes a path into the check that demands a key. Fixed in 0.5.149.

The lesson is not "audit your allowlist". It is that an allowlist and a router are two descriptions of the same URL space, maintained separately, and they drift. kRouter's fix was a test that asserts the invariant rather than the one path: every root-level rewrite source must have its top-level prefix guarded.

Shape 2: a URL from the request, and your key goes with it

CVE-2026-59823 is the key-leak one. LiteLLM did filter request bodies — it blocked api_base and base_url. The filter inspected two top-level keys, and the user_config object, from which the proxy builds a router for that request, was not one of them. The same api_base nested one level down was never looked at.

A caller with a valid gateway key posts a body naming its own api_base; the gateway builds the request, attaches the stored provider key, and sends it to the caller's host while the real provider is never contacted
A guard that checks the top level of a body does not check inside it.

Two details the headlines dropped. The advisory rates it medium, not critical, and says exploitation requires a valid virtual key — so the threat model is a caller you already issued a key to, not an anonymous stranger. And GitHub's advisory database puts the fix in 1.83.9 for this one but 1.83.0 for the config bug, five months earlier; Check Point's summary line that both are resolved as of 1.83.9 is true but reads as if they shipped together.

kRouter's version was /v1/search, where a client-supplied base URL was returned verbatim and handed to eleven outbound request builders. Fixed in 0.5.136. The subtler instance is still live by design and worth describing, because it is the same shape without an attacker: the async image providers submit a job, read a status URL out of the provider's own response, and poll it — with the provider's credential in the headers. If that response can name a host, the key goes there. Those polls now run through a reader that resolves the hostname and re-validates every redirect hop rather than following it.

Shape 3: a credential your own documentation published

Quickstart snippets get pasted whole. kRouter's docs, and upstream 9router's, showed example values for the dashboard password and the session signing secret, and kRouter used them verbatim. Anyone who pasted the website's signing key had a key the internet knows and a stranger could forge a session with it.

The fix, in 0.5.162, is unglamorous: a list of the exact strings the documentation ever printed. A signing secret that matches one of them, or that is shorter than 32 characters, is ignored in favour of the random one kRouter generates in its data directory, with one warning in the log. A first-login password that matches one gets the built-in default's rule.

A two-by-two table of password state against caller location, where only the default-password off-machine cell is refused with a 403 and no cookie
The refusal happens before a session is issued, not as an advisory flag afterwards.

That rule is the part worth copying. The default password is not forbidden; it is made useless at a distance. A login with it from the machine kRouter runs on works, and from anywhere else returns 403 with no cookie set. An earlier version computed the same condition but set the cookie first and returned "you should change this" as a flag the caller could ignore — then use the session to turn authentication off. Order matters more than the check.

Under Docker your own browser is never on the machine, so the documented run command generates a password rather than printing one:

KROUTER_PASSWORD="$(openssl rand -base64 18)" && echo "Dashboard password: $KROUTER_PASSWORD"
docker run -d \
  -p 127.0.0.1:20128:20128 \
  -e INITIAL_PASSWORD="${KROUTER_PASSWORD:?run the line above first}" \
  -v "$HOME/.krouter:/app/data" \
  --name krouter \
  sifxprime/krouter:latest

Shape 4: a gate that trusts a value the caller chose

LiteLLM's KEV-listed MCP bypass, CVE-2026-59822, is this shape: when key validation failed, the fallback path replaced the failure with an empty auth object, so a fabricated Authorization header reached the MCP tooling. The caller supplied the thing that decided the outcome.

kRouter's was the Host header. Its "is this caller on loopback?" test read a value the client chooses, so any remote request sending Host: localhost counted as local — which grants key-free API access and exemption from the login rate limiter. Fixed in 0.5.135 by deriving the peer address from the TCP socket, stamped with a per-process secret the client cannot know or replay, and failing closed in production when that stamp is absent rather than falling back to the header.

Both are the same question: when your authorisation decision reads an input, ask who writes it.

What does not work

  • A deny-list of bad address strings. kRouter's SSRF guard was behind upstream's and had seven live bypasses: IPv4-mapped IPv6 in three spellings, NAT64, the deprecated IPv4-compatible form, and trailing-dot hostnames for both localhost. and the Google metadata name. The guard now parses an address into numeric groups and reasons about the value, because ::ffff:127.0.0.1 and ::ffff:7f00:1 are the same address and a string match is a coin flip on which one the URL parser hands you.
  • Checking only the first hop. A URL that passes the check can redirect into loopback. Validate each hop, or do not follow redirects.
  • Checking only literal addresses. 127.0.0.1.nip.io passes every literal test and resolves to loopback. kRouter's test suite pins exactly that gap: the synchronous check allows it, the resolving one rejects it.
  • Putting the check in each call site. This is the one that actually bit. The first attempt swapped in the hardened guard and updated no callers, which read as "SSRF fixed" while every hole stayed open.
  • "It is behind a firewall." kRouter binds 0.0.0.0 by default and says so at startup. Assume the port is reachable until you have checked from another machine.

What does: one reader, and a test that pins it

Top panel shows four call sites each making their own outbound request; bottom panel shows the same four funnelled into one guarded reader inside a dashed boundary labelled as enforced by a test
Replacing the guard was the easy half. Keeping every call site wired to it is the half that needs a test.

kRouter exposes three entry points from one file: a cheap literal check for rejecting obvious input at request-build time, a resolving check that adds DNS, and a fetch wrapper that re-validates every redirect. The tests do something more useful than exercising the guard — they read each call site's source and assert that it imports the right one and keeps no bare request of its own. A new image adapter that polls a provider-supplied URL by hand fails that test instead of shipping the hole again.

ShapeWhere to look in your own gatewayWhat catches a regression
Prefix-matched allowlistEvery rewrite, alias and proxy pass next to the auth allowlistA test that derives the route list from the router config
Caller-supplied URLAny request or response field that becomes a base URL, image URL or callbackA test that fails any outbound call not routed through the guard
Published credentialEvery secret your own quickstart printsReject the literal strings; require a length floor on signing keys
Caller-chosen trust signalHost, X-Forwarded-For, any header that decides "local" or "admin"Derive from the socket; fail closed when you cannot prove the peer

Then check from another machine, which is the only test that counts:

curl -i https://your-router.example.com/v1/chat/completions
# expect 401, not a completion
 
curl -i -X POST https://your-router.example.com/api/auth/login \
  -H 'Content-Type: application/json' -d '{"password":"123456"}'
# expect 403 and no Set-Cookie

Where kRouter is not the answer

It has no roles. There is one dashboard identity. LiteLLM's privilege-escalation class — a low-privilege user reaching an admin endpoint — cannot happen here because there is no low-privilege user. That also means there is no least-privilege tier to fall back on: whoever has the login has everything.

Credentials are not encrypted at rest. OAuth tokens and API keys sit in the SQLite database in the data directory. Anyone who can read that file, or a backup of it, can use your accounts. Treat it like ~/.ssh.

Provider base URLs you configure yourself are trusted and bypass the SSRF guard entirely, which is how a local Ollama works at all. That makes dashboard access, not the guard, the thing protecting you there.

Request bodies are stored by default. Observability keeps the latest records, prompts included, in the same database. Turn it off in Settings if that is not acceptable.

And this took several releases. 0.5.135, 0.5.136, 0.5.149, 0.5.162 and 0.5.166 each closed part of it, because every new feature that fetched something added a call site. The guard was the easy part.

Common questions

Am I affected if I run LiteLLM? Check your version against the advisories, not the blog summaries. As of 19 October 2026 GitHub's database gives 1.83.0 for the config endpoint and the password-hash issues, 1.83.7 for the SQL injection and the MCP command execution, 1.82.0 for the guardrails bypass, 1.83.9 for the user_config SSRF and 1.84.0 for the MCP authentication bypass. Upgrading past the highest of those covers all six.

Do these have CVSS scores? Two of them do: 9.8 for the SQL injection and 8.8 for the MCP command execution. The October pair, the MCP authentication bypass and the password-hash advisory carry no score in GitHub's database. Severity labels are there; invented numbers are not.

Is self-hosting worse than a managed gateway? Different, not worse. Self-hosting means you own patching and exposure. A managed gateway means someone else owns them and you find out when they publish. The KEV entries are for software that both kinds of operator run.

Does any of this need an attacker on the internet? The user_config SSRF needs a valid key, so it is an insider or a leaked key. The SQL injection did not: a crafted Authorization header on any LLM route reached it unauthenticated. kRouter's /codex bypass did not either. "Only authenticated users can do it" is a real mitigation and a thin one.

What is the single highest-value check? Run the two curl commands above from a different network. Most gateway exposure is not an exotic bug; it is a port that answers.

Kodelyth · The team behind kRouter

Published by Kodelyth, the team that builds kRouter. Posts are drafted with AI assistance and reviewed by a person before they go out. kRouter is free and MIT licensed.

Install kRouter