security: keep machine identity on delegated tokens and the required_relations gate (GHSA-vq29-8q3c-3hrm) - #789
Merged
Conversation
A delegated token minted from a SERVICE-ACCOUNT subject dropped the login_method claim. service/fga.go treats a missing login_method as a human user, so an autonomous machine laundered itself into "user:<sub>": OpenFGA decisions flipped deny -> allow and every login_method-keyed guard was bypassed. The report framed this as self-delegation (subject == actor) and proposed rejecting that shape. That is a symptom patch. The laundering lives in CreateDelegatedAccessToken omitting the claim, so it applies identically to the multi-hop agent chain the design explicitly supports and which the proposed check does not cover. Both shapes are now covered by tests, and the self-delegation check is not added: once the subject keeps its real identity, subject == actor yields an ordinary attenuated, resource-bound machine token. resolveFgaCaller's machine branch now RETAINS actorID. It used to drop it, on the reasoning that a machine subject is never delegated — true of client_credentials, false of a machine-subject delegation, which now reaches that branch. Dropping it would collapse authority from perms(agent) n perms(subject) to perms(subject), trading identity laundering for privilege widening. Blast radius is one grant: CreateDelegatedAccessToken has exactly one caller (handleTokenExchangeGrant). authorization_code, implicit, refresh, client_credentials, OIDC id_token/userinfo, SAML and SCIM never reach it. A USER-subject delegation still carries no login_method and is unchanged. Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
Stamping login_method makes a previously-broken path work: before, a machine-subject delegated token carried no login_method, so re-exchanging it took the USER branch, did GetUserByID(<service-account row id>), found nothing and rejected the hop. A machine-subject chain only ever worked for its first hop (400 on hop 2). It now takes the agent branch and succeeds, which is what the multi-hop design intends. Recorded as a test rather than left as a surprise, along with the controls that bound it: subject liveness is re-checked every hop, scope still attenuates monotonically, and maxActChainDepth still caps the chain. Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
enforceRequiredRelations resolved the caller and then discarded caller.subject, hardcoding "user:"+userID. machineFgaSubject therefore never ran on this surface, so a machine identity was answered as a human user — the same laundering primitive as the delegated-token bug, but reachable with NO delegation at all: a plain client_credentials token presented to validate_jwt_token with required_relations was satisfied by a tuple written for "user:<service-account-row-id>", while check_permissions denied the identical token. That is the "two answers to one authority question" this function's own doc comment warns about, and a gateway gating on required_relations would admit requests the permission API refuses. Classification is extracted into fgaSubjectFor so all three decision surfaces share one source of truth, and the presented token's own login_method is threaded in from validate_jwt_token. The browser-session callers pass "" — a session subject is always a user. Found by adversarial review, not by the original report. Pre-existing on main rather than introduced by the delegation fix, but the same class, so it ships with it. Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes GHSA-vq29-8q3c-3hrm (high), plus a second instance of the same primitive found in review. Developed in the advisory private fork; opened here to merge and ship in 2.4.1.
The vulnerability
An RFC 8693 delegated token minted from a service-account subject dropped the
login_methodclaim.service.resolveFgaCallertreats a missinglogin_methodas a human user, so a machine identity laundered intouser:<sub>— flipping OpenFGA decisions deny→allow and slipping past everylogin_method-keyed guard.The reported root cause was wrong
The report framed this as self-delegation (
subject == actor) and proposed rejecting that shape. That is a symptom patch: the laundering lives inCreateDelegatedAccessTokenomitting the claim, so it applies identically to the multi-hop A→B agent chain the design explicitly supports and which the proposed check does not catch. Both shapes are asserted in tests; both fail against pre-fix code.The self-delegation rejection is deliberately not added — once the subject keeps its real identity,
subject == actormints an ordinary attenuated, resource-bound machine token.Second instance, found in review
enforceRequiredRelationsresolved the caller and then discardedcaller.subject, hardcoding"user:"+userID.machineFgaSubjectnever ran on that surface, so a machine identity was answered as a human user there too — with no delegation at all: a plainclient_credentialstoken presented tovalidate_jwt_tokenwithrequired_relationswas satisfied by a tuple written foruser:<service-account-row-id>, whilecheck_permissionsdenied the identical token. That is the "two answers to one authority question" the function's own doc comment warns about, and a gateway gating onrequired_relationswould admit requests the permission API refuses.Classification is now extracted into
fgaSubjectFor, so all three FGA decision surfaces share one source of truth.Three claims in the report that are wrong
service_account:<sa.ID>/access_token_<nonce>(token.go:1138,1164), exactly whatdelegationSessionIsLivelooks up.TestDelegatedMachineTokenRevocationWorkspasses on unpatched code.delegated_access_token.gostates it verbatim; first-party acceptance is the documented contract.requireOrgAdminbypass — zero impact. The membership lookup already denies, since a laundered token'ssubis a client id.Version range correction
Submitted as
> 2.3.0-rc.10. Wrong in both directions:2.3.0-rc.10is affected, and2.3.0is not — it is not a descendant of2.3.0-rc.10(divergent release lines) and contains no token-exchange code at all. Affected:2.3.0-rc.10, and2.4.0-rc.0…2.4.0.Backward compatibility
CreateDelegatedAccessTokenhas exactly one caller, soauthorization_code, implicit,refresh_token,client_credentials, OIDCid_token/userinfo/discovery/JWKS, SAML, SCIM, magic link and social login are untouched. User-subject delegation is unchanged — still nologin_method, stilluser:<sub>— and asserted so a future change cannot stamp it.Intentional changes:
service_account:<client_id>. Tuples written againstuser:<sa-row-id>stop matching — the vulnerability closing.login_method, so hop 2 took the user branch, didGetUserByID(<service-account row id>), found nothing and rejected — machine chains only ever worked for one hop. Still bounded: subject liveness re-checked per hop, monotonic scope attenuation,maxActChainDepth. Pinned byTestChainedMachineSubjectReExchange.resolveFgaCallernow retainsactorIDin the machine branch. It dropped it before, on reasoning true ofclient_credentialsbut false of a machine-subject delegation, which now reaches that branch — dropping it would collapse authority from perms(agent) ∩ perms(subject) to perms(subject), trading identity laundering for privilege widening. Guarded by a test.Verification
go build/go vetclean;make test0 failures;make smokeall pass (incl.mcp_delegated,oauth_authorization_code_+_PKCE,scim_provisioning, org-admin matrix);make lintclean in changed files. Every regression test confirmed to fail pre-fix.https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR