The Host header can decide which path your auth check sees

Local Fitness · No. 107

Shipped

This release closed an authentication bypass in a personal fitness agent’s MCP server, alongside a batch plan-edit tool, a persona rewrite, and a round of deduplication. The bypass is the part worth teaching: a crafted Host header made the auth middleware believe a protected request was aimed at the one public route, so the bearer-token check returned early and never ran.

The whitelist function was correct the whole time. The bug was in what got passed to it. If you have a Python service that makes an authorization decision from a URL path, this is a fifteen-minute audit with a one-line fix, and the test that proves it is worth more than the fix.

Why a path can be two different strings

An ASGI server parses the request line and puts the target in the connection scope. The ASGI specification defines path as the “HTTP request target excluding any query string, with percent-encoded sequences and UTF-8 byte sequences decoded into characters.” It comes from the request line. Headers are a separate key in the same scope, and nothing about path derives from them.

Starlette’s request.url is a different animal. It is reconstructed, by concatenating the Host header with the path and re-parsing the result. That is convenient when you want an absolute URL, and it is a liability when you want a routing decision. PortSwigger’s Web Security Academy puts the root cause plainly: Host header vulnerabilities arise from the flawed assumption that the header is not user controllable. Anyone can set it to anything.

CVE-2026-48710 is exactly this. In starlette 1.0.0 and earlier, a Host header containing /, ?, or # shifts the path, query, and fragment boundaries when the rebuilt URL is re-parsed. The advisory is rated moderate, CVSS 6.5, and patched in 1.0.1. The router still dispatches on the real path; only code reading request.url.path sees the moved one.

OWASP has catalogued the general class for years. Its Web Security Testing Guide lists redirects to attacker-controlled domains, cache poisoning, and password-reset manipulation as consequences of processing the header without validation. Path-based authorization joins that list whenever a framework builds a URL out of it.

Set up a service worth attacking

Two dependencies, and a deliberately old starlette so you can watch the bug happen:

python -m venv .venv && source .venv/bin/activate
pip install 'fastapi==0.136.1' 'starlette==1.0.0' 'httpx' 'pytest' 'anyio'

The shape below is the common one: everything is private except a liveness probe that a container orchestrator has to reach without credentials.

# app.py
import os
import secrets

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

API_TOKEN = os.environ.get("API_TOKEN") or None

app = FastAPI()


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.get("/private/data")
async def private_data():
    return {"secret": "your users' data lives here"}

Write the gate the obvious way, and watch it fail

Here is the version almost everyone writes first. It reads the path off the request, checks it against a whitelist, and short-circuits for public routes.

# app.py (continued)
def is_public_path(path: str) -> bool:
    """Deny by default. Only the liveness probe is public."""
    return path == "/health"


@app.middleware("http")
async def require_token(request: Request, call_next):
    path = request.url.path            # <-- the bug
    if API_TOKEN is None or is_public_path(path):
        return await call_next(request)
    header = request.headers.get("authorization", "")
    if not secrets.compare_digest(header, f"Bearer {API_TOKEN}"):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    return await call_next(request)

Nothing about is_public_path is wrong. It is an exact match, it denies by default, and a unit test over it passes. The input is the problem.

You can see the divergence without running a server at all:

# show_divergence.py
from starlette.requests import Request

def scope(host: bytes) -> dict:
    return {
        "type": "http", "method": "GET", "path": "/private/data",
        "headers": [(b"host", host)], "query_string": b"",
        "scheme": "http", "server": ("example.local", 80),
        "client": ("10.0.0.9", 5555),
    }

for host in (b"example.local", b"example.local/health#"):
    request = Request(scope(host))
    print(f"Host={host.decode():24} url.path={request.url.path:16} scope={scope(host)['path']}")
Host=example.local            url.path=/private/data    scope=/private/data
Host=example.local/health#    url.path=/health          scope=/private/data

The router will dispatch to /private/data in both cases. The middleware sees /health in the second and waves it through.

Fix it at the source of the string

One accessor, used everywhere a path drives a security decision:

# app.py (replace the middleware above)
def request_path(request: Request) -> str:
    """The path the ROUTER dispatches on.

    Never request.url.path: starlette rebuilds that from the Host header, so a
    '/' in an attacker-supplied header moves the path boundary. scope["path"]
    is set by the ASGI server from the request line and no header can touch it.
    """
    return request.scope["path"]


@app.middleware("http")
async def require_token(request: Request, call_next):
    path = request_path(request)
    if API_TOKEN is None or is_public_path(path):
        return await call_next(request)
    header = request.headers.get("authorization", "")
    if not secrets.compare_digest(header, f"Bearer {API_TOKEN}"):
        return JSONResponse({"error": "unauthorized"}, status_code=401)
    return await call_next(request)

Upgrading starlette fixes this specific CVE, and you should do that too. It is the weaker half. The bump closes one instance; reading the router’s own path closes the class, and it keeps working when a transitive dependency re-pins starlette underneath you.

Verify it, against your own service

Point this at your app and run it. The first test is the invariant; the second is the end-to-end proof.

# test_auth_path.py
import httpx
import pytest
from starlette.requests import Request

import app as appmod


@pytest.fixture
def anyio_backend():
    return "asyncio"


HOSTILE = [
    b"example.local/health#",
    b"example.local/health?",
    b"example.local:8765/health#",
    b"/health#",
]


def test_request_path_is_invariant_under_any_host_header():
    for host in [b"example.local", *HOSTILE]:
        scope = {
            "type": "http", "method": "GET", "path": "/private/data",
            "headers": [(b"host", host)], "query_string": b"",
            "scheme": "http", "server": ("example.local", 80),
            "client": ("10.0.0.9", 5555),
        }
        request = Request(scope)
        assert appmod.request_path(request) == "/private/data"
        assert appmod.is_public_path(appmod.request_path(request)) is False


@pytest.mark.anyio
async def test_poisoned_host_cannot_bypass_the_token(monkeypatch):
    monkeypatch.setattr(appmod, "API_TOKEN", "test-token")
    transport = httpx.ASGITransport(app=appmod.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
        for host in HOSTILE:
            response = await client.get(
                "/private/data", headers={"Host": host.decode()})
            assert response.status_code == 401, (
                f"Host={host!r} bypassed the gate: {response.status_code}")


@pytest.mark.anyio
async def test_health_stays_public(monkeypatch):
    monkeypatch.setattr(appmod, "API_TOKEN", "test-token")
    transport = httpx.ASGITransport(app=appmod.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
        response = await client.get("/health", headers={"Host": "example.local"})
        assert response.status_code == 200
pytest test_auth_path.py -q
...                                                                      [100%]
3 passed in 0.09s

The third test matters more than it looks: an over-correction that locks down /health breaks every container healthcheck you own, and that failure shows up as a restart loop at an inconvenient hour rather than as a red test.

Now prove the tests bite, which is the step people skip. Put request.url.path back in the middleware and run them again:

E               AssertionError: Host=b'example.local/health#' bypassed the gate: 200
E               assert 200 == 401
E                +  where 200 = <Response [200 OK]>.status_code

FAILED test_auth_path.py::test_poisoned_host_cannot_bypass_the_token
1 failed, 2 passed in 0.10s

A 200 where you demanded a 401, from a request carrying no credentials at all. If you revert the line and everything still passes, the test is not testing what you think it is, and you want to know that before you rely on it.

Gotchas

A defence you did not design is not a control. The exploit did not return 200 against the running service; it returned 421. A transport in front of the app had its own DNS-rebinding guard that checks Host against an allowlist, and its first branch is an exact string match. A poisoned header contains a /, so it can never match anything, and the guard answers Response("Invalid Host header", status_code=421). That is the attack’s shape colliding with an unrelated check, not a control anyone designed for this.

Read the next branch of that same guard, though:

for allowed in self.settings.allowed_hosts:
    if allowed.endswith(":*"):
        base_host = allowed[:-2]
        if host.startswith(base_host + ":"):
            return True

Configure the allowlist as example.local:*, which is a documented option, and example.local:8765/health# satisfies that prefix test. The guard returns True, the auth middleware has already returned early on its poisoned path, and the request reaches the application with no credentials. When an exploit dies somewhere you did not put a control, find the line that stopped it and read what else that line does before you downgrade the finding.

A green unit test on the pure function proves nothing about its callers. is_public_path had a test asserting deny-by-default, and it passed through the entire vulnerable period. It was never wrong. Test the seam where the caller supplies the input, not just the function in isolation.

Do not assert a third party’s bug in your regression test. My first version of the invariant test opened with a precondition I was pleased with: assert request.url.path == "/health", proving the installed starlette really was poisonable. Then I bumped starlette to 1.3.1 and the test failed with assert '/private/data' == '/health'. That draft was a CVE detector. Once the library was patched it would have passed forever while asserting nothing about my code, which is the worst kind of green. Pin the invariant your code must hold, across several hostile inputs, and let it hold on a patched library, a downgrade, and the next variant of the same class.

Your revert may fail with a stack trace instead of an assertion. The minimal app above fails cleanly, 200 against an expected 401. In the real service it did not: reverting raised RuntimeError: Task group is not initialized from inside a mounted sub-application, because the unauthenticated request now reached a mount whose lifespan the test never started. Reaching the mount at all was the proof that the gate had been skipped, so the test was still correct. Write that expectation into the docstring anyway, or the next person reads the traceback as flaky infrastructure and re-runs it until it goes away.

Sources

Changelog

  • release: 0.49.0 — MCP-surface audit (auth bypass, invisible draft, batch plan edit, dedup) (#197) (917db6b)