Matteo Panzeri/ ← index

Notebook · 2026-06-11

The token-exchange grant that skipped dex's connector allow-list

GHSA-7qjx-gp9h-65qj (CWE-285): dex enforced a client's per-client connector allow-list on its other OAuth2 entry points, but the token-exchange grant read a connector ID straight from the request and never checked it against that allow-list.

TL;DR

In dexidp/dex, a client can carry an optional allow-list field, client.AllowedConnectors. When it is set, that client may authenticate only through the connectors named in it. The helper that enforces this, isConnectorAllowed, was called on the authorization-request path and on the connector-login path, but it was not called on the OAuth2 token-exchange grant. That grant read connector_id from the request and resolved the connector without first asking whether the client was permitted to use it. So a confidential client whose allow-list restricted it to a subset of connectors could obtain a token through a connector it was forbidden to use everywhere else. It is now GHSA-7qjx-gp9h-65qj, CWE-285 (Improper Authorization), confirmed High by the maintainer, with no CVE assigned because the gap lived only on the master branch and never shipped in a released tag. I reported it and I wrote the fix (PR #4784). I am writing it up for the method, not the severity: this is a worked example of finding the one entry point that drops a check its siblings keep.

The bug

The allow-list check itself is small. An empty list means any connector is allowed; a non-empty list is a membership test:

// server/handlers.go:318
func isConnectorAllowed(allowedConnectors []string, connectorID string) bool {
    if len(allowedConnectors) == 0 {
        return true // empty list = any connector allowed
    }
    for _, id := range allowedConnectors {
        if id == connectorID {
            return true
        }
    }
    return false
}

Pre-fix, handleTokenExchange (at server/handlers.go:1804) read connID := q.Get("connector_id"), a request parameter marked REQUIRED in the in-code comment, and then called s.getConnector(ctx, connID) directly, with no allow-list check between the two. The other request-driven entry points did make that check. The fix is seven lines (a six-line guard plus its blank separator line, matching the +7 additions to server/handlers.go in the diff), inserted immediately before the getConnector call, which shifts that call to line 1842 in the patched file:

// server/handlers.go, PR #4784, inserted before getConnector
if !isConnectorAllowed(client.AllowedConnectors, connID) {
    s.logger.ErrorContext(r.Context(), "connector not allowed for client",
        "connector_id", connID, "client_id", client.ID)
    s.tokenErrHelper(w, errInvalidRequest, "Connector not allowed for this client.", http.StatusBadRequest)
    return
}

The PR also adds TestHandleTokenExchangeAllowedConnectors with four cases: a connector in the allow-list returns 200, a connector matching a non-first entry in the allow-list returns 200, a connector not in the allow-list returns 400, and an empty allow-list permits any connector and returns 200. The third case is the one that fails without the patch.

The method: a sibling that drops a check its peers keep

The find did not come from grepping for a missing call. It came from treating isConnectorAllowed as a security-relevant action and enumerating every place a connector ID enters the server from request input, then asking which of those places ran the allow-list and which did not. Pre-fix, isConnectorAllowed was called at exactly two request-input-driven entry points and omitted at a third:

The signature of this method is the completeness check, so I owe the full enumeration rather than the single hit. There are six getConnector call sites in server/handlers.go. Three consume a connector ID taken fresh from request input: the authorization path in oauth2.go, handleConnectorLogin (the getConnector at :384, guarded by the allow-list at :377), and handleTokenExchange (the getConnector at :1842, which was the gap). The other three consume an already-validated stored ID and so do not need a fresh allow-list check:

So the fix at the token-exchange grant closes the complete set of request-input-driven connector-selection entry points. There is no remaining unguarded sibling, and stating that plainly is part of the work: the enumeration is what lets me say the patch is complete rather than hope it is. The discovery method generalizes past dex. Take a security-relevant action, list its siblings, and look for the one entry point that drops a check its peers enforce.

Severity, both ways

I self-assessed this as High, 8.7, CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N, and the maintainer confirmed High. The PR:H precondition is real and I want to state it without softening. To exploit this, the attacker must already hold the confidential client's client_secret and a valid subject_token, the client must have the token-exchange grant enabled, and AllowedConnectors must be set to a restrictive subset. Given all of that, the impact is that the restricted client escapes its own connector restriction and authenticates through a connector it was explicitly denied elsewhere, which is why the confidentiality and integrity impacts are High and the scope is changed.

The honest deflator goes next to the score, not in a footnote. The gap was introduced by PR #4610 (commit f80a89d) on 2026-03-11 and existed only on the master branch. No released tag ever carried it. Real-world exposure was bounded to operators running dex from master between 2026-03-11 and the 2026-05-11 fix. That is exactly why the maintainer assigned High but declined to request a CVE: the authorization weakness is real and worth the High rating, and at the same time it never reached a shipped release, so a CVE would overstate the deployed blast radius. High-but-no-CVE is not a half-result here. It is the calibrated outcome, and I would rather report both numbers than pick the flattering one.

Where the measurement fits

The disclosure and the sibling-diff method are the substance of this writeup. The measurement is a supporting exhibit, evidence that I hold my own methods to a baseline instead of asserting they work. That instrument is sota_bench, an open, model-agnostic benchmark with a deterministic non-LLM scorer that measures whether a method beats a single naive call to a frontier model, re-run on each release.

It has already published a result against this exact style of reasoning that I would not have chosen to advertise. On the authorization class, a static-prompt proxy of the sibling-guard method lost to a naive single call: recall 0.667 against 0.833, a signed delta of −0.167 on the pinned set. That number stays on the front page on purpose, because the point of the benchmark is to catch over-claiming, including mine. The one place a method of mine currently shows an edge, decode-completeness, sits on a tiny, underpowered sample (n=3, recall-only) behind an admission floor that mechanically refuses to call any rate below ten scored items a rate at all. So the only defensible claim I make is narrow and prior-art-led: the method recovers known findings on a small sample, the static proxy of it did not beat the model, and I do not call any of it novel without that qualification.

Credit and references

I was the sole reporter on GHSA-7qjx-gp9h-65qj, and I authored the merged fix in PR #4784 (DCO sign-off, Matteo Panzeri). Thanks to maintainer Maksim Nabokikh, who accepted the report and merged the fix quickly, and who made the calibrated call on severity and CVE. I am a BSc-AI student at the University of Pavia.