Skip to content

Open Source

CVE-2026-48710: A Maintainer's Perspective

Last week, I published the security advisory GHSA-86qp-5c8j-p5mr on GitHub, and now it seems the project is being aimed at by a barrage of negative press. I want to take a moment to share my perspective on this, and to share my point of view on the vulnerability, and share a bit about the process itself.

What is the vulnerability?

Routing uses the raw HTTP path, but request.url is reconstructed by concatenating http://{host}{path}, where host comes from the Host header. Since the client controls that header, request.url.path can be made to differ from the path the request was actually routed on.

This matters when a middleware uses request.url.path to guard a route:

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

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        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")

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

Now send a request to /admin/potato with Host: example.com/?. The router still dispatches to the potato endpoint, because routing uses the raw path. But request.url is reconstructed as http://example.com/?/admin/potato, so request.url.path is /. The middleware's check never fires, and the secret leaks.

You may look at this and say: "uau, that's a pretty bad bug". It can be. But the assumptions matter when you reason about where the vulnerability actually lives:

  1. An application uses path-based authorization in middleware. This is an application pattern built on top of Starlette, not something Starlette does for you.
  2. Routing itself is never fooled. The router dispatches on the raw HTTP path, so the endpoint that runs is always the correct one. The divergence only bites code that re-derives authorization from the reconstructed URL.
  3. There's no CDN, load balancer, API gateway, or fronting web server validating the Host header. Any of those neutralizes the attack by rejecting malformed values.

There's a more fundamental point here, and Giovanni Barillari put it well in the advisory thread: authorization and authentication should not be based on the request's path, host, or query string in the first place. That's a fragile pattern regardless of this bug. Trailing slashes, case sensitivity, percent-encoding, and path normalization all bite the same code in the same way. The Host header is just one more thing that can make the string you matched on differ from the request you actually served.

In short, the vulnerability came from the application pattern and the deployment, never from something Starlette intended.

Why was there even a CVE then?

I think the article OSTIF did explains this well:

This bug is a classic “responsibility gap” where if this maintainer didn’t patch, thousands of exposed projects would have to individually secure their projects. In doing this work, they’ve voluntarily taken on the responsibility to protect the ecosystem from long-term systemic harm. As with all open source projects, they owed us nothing and could have left this to be everyone else’s problem and took the extraordinary steps of helping the ecosystem. Please consider donating to Kludex: https://github.com/sponsors/Kludex

I think this summarizes it well.

On the disclosure process

I want to be fair: the people at X41 D-Sec were mostly polite, apologized when I pushed back, and were clearly acting in good faith. But there are a few things about the process I disagree with.

The first was the initial deadline. The very first message imposed a roughly one-month disclosure window and offered a joint call "in 2 or 5 days". That treats unpaid maintainers like a corporation with an on-call security team. Everyone involved in the internal discussion has a full-time job and maintains these projects in their free time. To their credit, X41 walked the deadline back once this was raised.

The part I find hardest to accept is, near the end, they suggested publishing the advisory before a patch was available. That is horrible practice. A public advisory with no available fix leaves every affected user exposed with nothing to do but wait, while attackers get the exact same information. The whole point of coordinated disclosure is that the fix lands first. Suggesting the reverse, on a package this widely deployed, is the opposite of protecting users.

The other thing that bothered me is that X41 built badhost.org, a branded landing page with a logo, a name, and an Internet-wide scanner. That is marketing a vulnerability. Spinning up a dedicated site the moment the advisory drops gives users no time to react: the CVE databases haven't propagated yet, tools like Dependabot haven't fired their alerts, and most people haven't even had the chance to bump the dependency. The branding gets the attention; the people running the affected software get to find out from a headline.

Unprofessional behavior from Ars Technica

The article Ars Technica published mentioned this:

The developer of Starlette didn’t immediately reply to an email seeking confirmation of the assessment and additional information.

For public disclosure, this is the email they sent to me:

Email from Ars Technica reporter Dan Goodin: "Hi. Ars Technica reporter Dan Goodin here. I'm contacting you about CVE-2026-48710. How severe is it? Do you know how many users have patched so far? Starlette receives 325 million downloads weekly, yes? This seems like a potentially serious even if large percentages of this base don't update. Can you provide answers and insights ASAP? Regards."

This email was sent couple of hours before the article was published. I feel like it's in my right to not reply to the rude, and demanding tone of this email. I've never interacted with this person before, and I don't think I owe anything to them.

I don't read Hacker News or Reddit, unless some friend points me to a specific thread. Also... I got shamed by my friends because I didn't know what Ars Technica was. But after today, I'm pretty sure I'll not be reading anything from them anyway.

The burden of triaging security advisories

Least but not last, I want to talk about the burden of triaging security advisories. I don't have a security team, and every advisory lands on me personally. Most weeks I'm reviewing one almost every day, and the majority are noise - coding agents generating plausible-looking reports that take real time to disprove. A genuine one like this is rare, and it still has to compete for the same evenings and weekends as everything else.

I wrote about this in the Starlette 1.0 post: advisories are the tricky part of maintenance, because dismissing a real one is dangerous and entertaining a fake one is expensive. I'll keep doing it, but it's worth understanding the cost behind the calm response you eventually see.

What you should do

Upgrade to Starlette 1.0.1 or later, which validates the Host header and rejects malformed values.

Beyond that: don't base authorization on request.url.path. If you need the routed path, use request.scope["path"], which is never reconstructed from the Host header. Better yet, don't make authorization decisions on path strings at all.

Thank you

The thing that stuck with me most was not the criticism, but how many people pushed back on the framing without being asked to. On the Ars thread, person after person took the time to explain that this was an old-fashioned bug, not "AI slop", and that the article's spin did readers a disservice. The comments attacking the project were few, and mostly down voted.

To everyone who read past the headline and engaged with what actually happened: thank you. ❤

Starlette 1.0 is here!

A few weeks after the 1.0.0rc1 release, we are ready to welcome the long awaited 1.0. 🎉

Starlette 1.0 is not about reinventing the framework or introducing a wave of breaking changes. It is mostly a stability and versioning milestone. The changes in 1.0 were limited to removing old deprecated code that had been on the way out for years, along with a few bug fixes. From now on we'll follow SemVer strictly.

Acknowledgement

Before we continue, I'd like to thank the people that helped shape the project into what it is today.

First and foremost, thank you to Mia Kimberly Christie for creating Starlette! 🙏

Over the years, many people have shaped this project. Thomas Grainger and Alex Grönholm taught me so much about async Python, and have always been ready to help and mentor me along the way. Adrian Garcia Badaracco, one of the smartest people I know, who I have the pleasure of working with at Pydantic. Aber Sheeran has been my go-to person when I need help on many subjects. Florimond Manca was always present in the early days of both Starlette and Uvicorn, and helped a lot in the ecosystem. Amin Alaee contributed a lot with file-related PRs, and Alex Oleshkevich helped on templates and many discussions. Sebastián Ramírez maintains FastAPI upstream, and has always been in contact to help with upstream issues. Jordan Speicher worked on making Starlette anyio compatible.

On the support side, Seth Michael Larson has been someone I've relied on for help with security vulnerabilities, and Pydantic, my company, has supported me in maintaining these open source projects. A special thanks to our sponsors as well: @tiangolo, @huggingface, and @elevenlabs.

Starlette in the last year

Since the 2024 Open Source Report, here's what happened (data gathered from the GitHub API and PyPI Stats):

Downloads/month Releases Closed issues Merged PRs Closed unmerged PRs Answered discussions
325 million 19 50 144 77 49

Compared to last year (57 million downloads/month), Starlette has grown to 325 million downloads/month - almost 6x growth!

Open Source in the Age of AI

A lot of my work at Pydantic lately, building Logfire, has been focused on AI, and that has influenced my day-to-day work quite a bit, including how I maintain Starlette.

In practice, that has mostly meant using coding agents to speed up issue triage and pull request review.

The most negative side lately has been the amount of issues, pull requests and advisories opened via coding agents, that are just noise. Issues and pull requests are easy to close, but advisories are tricky - sometimes they look real, and making a judgement usually takes a long time.

What's next?

Looking ahead, we'll probably focus on improving the performance of our routing and multipart parsing. The number of issues in Starlette is down to 15 lately, so the idea is to keep maintaining the project as is. We'll be following SemVer now, and I don't foresee version 2 any time soon, but I'm also not afraid of doing that if we introduce some cool breaking change.

Go ahead and bump your Starlette version, and if you'd like to support the continued development of Starlette, consider sponsoring me on GitHub. ❤

Oh, and Sebastián, Starlette is now out of your way to release FastAPI 1.0. 😉

2024 Open Source Report

This is my first yearly report on Open Source! 🎉

I dedicate a lot of my free time doing Open Source work, and I would like to share with you some numbers. I hope you find them interesting!

Project Downloads/month Time spent Releases Closed issues Merged PRs Closed unmerged PRs Answered discussions
Starlette 57 million 70 hrs 29 mins 29 76 182 64 93
Uvicorn 49 million 48 hrs 3 mins 20 61 100 65 38
Python Multipart 25 million 17 hrs 29 mins 13 37 87 12 0
Total 131 million 136 hrs 1 min 62 174 369 141 131

Most of the time dedicated in maintaining open source projects is actually not spent coding, as most of people think. It's mainly on interacting with people: answering questions, reviewing pull requests, and investigating issues.

Sponsors

I would like to thank all the sponsors that supported me in 2024! ❤

Data Analysis

I got this data from a script I created that queries the GitHub API and WakaTime API.

Click here to see the script...

Most of the script was created with the help of Claude AI, but I had to tweak it a bit to get the data I wanted.

If you want to use it, make sure you have the following environment variables set:

  • WAKATIME_API_KEY: Your WakaTime API key.
  • GH_TOKEN: Your GitHub token.
import os
import httpx
from datetime import datetime, timedelta
from wakatime_client import WakatimeClient


def main():
    client = WakatimeClient(api_key=os.getenv("WAKATIME_API_KEY"))
    for project in client.stats(range="last_year")["data"]["projects"]:
        if project["name"] in ("starlette", "uvicorn", "python-multipart"):
            print(f'{project["name"]}: {project["text"]}')
    print()

    print(f"starlette releases: {count_releases('encode', 'starlette')}")
    print(f"uvicorn releases: {count_releases('encode', 'uvicorn')}")
    print(f"python-multipart releases: {count_releases('Kludex', 'python-multipart')}")
    print()
    print(f"starlette stats: {get_repo_stats('encode', 'starlette')}")
    print(f"uvicorn stats: {get_repo_stats('encode', 'uvicorn')}")
    print(f"python-multipart stats: {get_repo_stats('Kludex', 'python-multipart')}")
    print()
    print(f"starlette activity: {get_repo_activity('encode', 'starlette')}")
    print(f"uvicorn activity: {get_repo_activity('encode', 'uvicorn')}")
    print(f"python-multipart activity: {get_repo_activity('Kludex', 'python-multipart')}")


def count_releases(owner: str, repo: str):
    url = f"https://api.github.com/repos/{owner}/{repo}/releases"
    headers = {"Accept": "application/vnd.github.v3+json", "Authorization": f"Bearer {os.getenv('GH_TOKEN')}"}

    with httpx.Client() as client:
        response = client.get(url, headers=headers)
        response.raise_for_status()

        one_year_ago = datetime.now() - timedelta(days=365)
        return sum(
            1
            for release in response.json()
            if datetime.strptime(release["published_at"], "%Y-%m-%dT%H:%M:%SZ") > one_year_ago
        )


def get_repo_stats(owner: str, repo: str):
    headers = {"Accept": "application/vnd.github.v3+json", "Authorization": f"Bearer {os.getenv('GH_TOKEN')}"}

    base_url = f"https://api.github.com/repos/{owner}/{repo}"
    since = (datetime.now() - timedelta(days=365)).isoformat()

    try:
        with httpx.Client() as client:
            # Get issues (excluding PRs)
            issues_count = 0
            issues_url = f"{base_url}/issues"
            issues_params = {"state": "closed", "since": since}

            issues_response = client.get(issues_url, headers=headers, params=issues_params)
            issues_response.raise_for_status()

            while issues_response.status_code == 200:
                issues_count += sum(1 for issue in issues_response.json() if "pull_request" not in issue)

                if "Link" in issues_response.headers and 'rel="next"' in issues_response.headers["Link"]:
                    next_url = [
                        link.split(";")[0].strip("<> ")
                        for link in issues_response.headers["Link"].split(",")
                        if 'rel="next"' in link
                    ][0]
                    issues_response = client.get(next_url, headers=headers)
                else:
                    break

            # Get PRs
            prs_url = f"{base_url}/pulls"
            merged_count = 0
            closed_count = 0

            # First get merged PRs
            pr_params = {"state": "closed", "sort": "updated", "direction": "desc"}
            pr_response = client.get(prs_url, headers=headers, params=pr_params)
            pr_response.raise_for_status()

            while pr_response.status_code == 200:
                for pr in pr_response.json():
                    # Check if PR was updated in the last year
                    if datetime.strptime(pr["updated_at"], "%Y-%m-%dT%H:%M:%SZ") < datetime.now() - timedelta(days=365):
                        break

                    if pr["merged_at"]:
                        merged_count += 1
                    else:
                        closed_count += 1

                if "Link" in pr_response.headers and 'rel="next"' in pr_response.headers["Link"]:
                    next_url = [
                        link.split(";")[0].strip("<> ")
                        for link in pr_response.headers["Link"].split(",")
                        if 'rel="next"' in link
                    ][0]
                    pr_response = client.get(next_url, headers=headers)
                else:
                    break

            return {"closed_issues": issues_count, "merged_prs": merged_count, "closed_unmerged_prs": closed_count}

    except httpx.HTTPError as e:
        print(f"Error fetching repository stats: {e}")
        return None


def get_repo_activity(owner: str, repo: str):
    headers = {"Accept": "application/vnd.github.v3+json", "Authorization": f"Bearer {os.getenv('GH_TOKEN')}"}

    # GraphQL query for discussions (REST API doesn't support discussions)
    graphql_url = "https://api.github.com/graphql"
    query = """
    query($owner:String!, $repo:String!) {
    repository(owner: $owner, name: $repo) {
        discussions(first: 100, answered: true, orderBy: {field: UPDATED_AT, direction: DESC}) {
        totalCount
        nodes {
            answerChosenAt
        }
        }
    }
}
"""

    with httpx.Client() as client:
        # Get discussions via GraphQL
        response = client.post(
            graphql_url, json={"query": query, "variables": {"owner": owner, "repo": repo}}, headers=headers
        )
        response.raise_for_status()

        one_year_ago = datetime.now() - timedelta(days=365)
        data = response.json()

        return sum(
            1
            for discussion in data["data"]["repository"]["discussions"]["nodes"]
            if datetime.strptime(discussion["answerChosenAt"], "%Y-%m-%dT%H:%M:%SZ") > one_year_ago
        )


main()