Matteo Panzeri/ ← index

Notebook · 2026-06-09

A confused deputy in GitHub's own MCP server

CVE-2026-48529: in lockdown mode, github-mcp-server bound every user's repository-access checks to the first user's token. A small, real instance of the class I work on, and a reason to be honest about what finding it does and does not prove.

TL;DR

The official github-mcp-server, run in HTTP mode with --lockdown-mode, kept its repository-access cache as a single process-global object initialized with the first authenticated user's GraphQL client. Every later user shared it, so their lockdown access checks ran under the first user's token: a textbook confused deputy at the agent dispatch layer. It is now CVE-2026-48529 (GHSA-pjp5-fpmr-3349, CVSS 6.0, CWE-284), fixed in 1.1.2. I was credited as a reporter, alongside another researcher who reported it independently. I am writing it up not because a medium-severity bug is remarkable, but because it is a clean example of the bug class my measurement work is built around, and a good occasion to say plainly what a disclosure like this is worth.

The bug

In HTTP mode, lockdown decides whether content from external contributors is trusted or sanitized, by asking GitHub (over GraphQL) what access the caller has to a repository. The cache that answers those questions was a singleton:

// pkg/lockdown/lockdown.go (pre-1.1.2)
var (
    instance   *RepoAccessCache
    instanceMu sync.Mutex
)

func GetInstance(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessCache {
    instanceMu.Lock()
    defer instanceMu.Unlock()
    if instance == nil {
        instance = &RepoAccessCache{ client: client } // stored on the FIRST call only
    }
    return instance // every later caller gets the same object, with the first client
}

Per request, the server builds a GraphQL client bound to that request's token and passes it in, but the singleton's client field is never updated after the first initialization. So once one user has made a request, every subsequent user's lockdown checks (queryRepoAccessInfo, reached through IsSafeContent, which gates trust decisions in at least six places across the issues and pull-request handlers) execute under the first user's credentials. The advisory's proof of concept is minimal: build two distinct GraphQL clients, call GetInstance twice, observe the same pointer, the second client silently dropped. Affected versions are >= 0.22.0, < 1.1.2; the fix scopes the cache per request.

Why I care about this exact shape

My named specialization is agent tool-dispatch authorization-confusion: an authorization decision that should be made per caller, at the point a tool or agent surface dispatches a privileged action, is instead skipped, made on the wrong path, or, as here, made once and reused. The usual variant is a guard present on the REST path but missing on the agent path. This is the cross-user variant: the guard runs, but its identity context is global, so one principal's access silently stands in for another's. Same root, a deputy that acts with authority that is not the caller's.

It is a good case because the mechanism is unglamorous and easy to miss in review: there is no missing check to grep for. The check is present; what is wrong is its lifetime. That is exactly the kind of thing a static read can rationalize away, and exactly why I think the interesting question is not "can a model find bugs" but "can we measure when a method actually beats a plain model call at telling the real ones from the look-alikes."

What a disclosure like this is, and is not, worth

Models now surface candidate vulnerabilities at enormous scale, so raw discovery is commoditizing. What does not commoditize is a named specialization shown through real disclosures and a repeatable way of finding them. A credited CVE in a widely-used, GitHub-owned project is a credential: one labeled, public, dup-verifiable instance of the authz-confusion class I work on, and a real example of the sibling-diff method at work. The disclosure and the method are the substance. Alongside them I build a supporting exhibit, an open, model-agnostic benchmark and non-LLM scorer (sota_bench) that measures whether a given method beats a naive single call to a frontier model, re-run on every new release, so the claim that my methods add value is something I can check rather than assert.

That exhibit has already told me something I would rather not have published: on the authorization class, a static-prompt version of my own sibling-guard method lost to a naive single call (recall 0.667 versus 0.833, a signed delta of −0.167 on the pinned set). The detail is in the methodology writeup. I keep that result on the front page on purpose. The disclosure and the repeatable method are the substance teams hire on; the benchmark is the supporting exhibit that says, honestly, where my methods do and do not add value over the model itself, including this published loss.

Keeping myself honest

Two disciplines guard against the easy story. First, the scorer is not a model: every metric is a closed-form function of labels, and an admission floor mechanically refuses to call anything a "rate" below ten scored items, so I cannot promote an anecdote into a measured edge. Second, a commoditization watch: the one class where a method of mine currently shows an edge (decode-completeness) rests on a tiny, underpowered sample, and the edge is a single checklist behavior a stronger model could internalize on its own. So I test it against each new frontier model and pre-register what each outcome will mean before I run it. If the next model does the work my method does, the honest conclusion is that this particular heuristic commoditized, and the measurement is what lets me prove it cleanly; the durable asset stays the specialization and the discovery method, continuously re-tested.

Credit and references

This was reported independently by two people and we are both credited; I do not claim sole discovery. Thanks to the github-mcp-server maintainers for a fast, clean fix and for crediting reporters.