Skip to content

security(core): stop scoped tokens from escalating via token create and login - #7957

Closed
ar2rsawseen wants to merge 2 commits into
masterfrom
security/token-create-full-permission
Closed

security(core): stop scoped tokens from escalating via token create and login#7957
ar2rsawseen wants to merge 2 commits into
masterfrom
security/token-create-full-permission

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Summary

A token deliberately scoped to one app could escalate to the owner's full account. Two independent gaps, closed from both ends.

The chain (verified on master):

  1. A token's app/endpoint restriction is a data-scoping control — verify_token compares it to the request's app_id, and only when one is present. /i/token/create carries no app_id and isn't app-specific, so the restriction never gates it (supplying the permitted app passes; omitting app_id skips the check entirely).
  2. validateUser resolves any bearer token to its owner and loads the full member, and the create handler then saved caller-supplied apps/endpoint/purpose with no relationship to the authenticating credential (requestProcessor.js). A restricted token could mint an unrestricted child (→ reads any of the owner's apps) or a LoggedInAuth child.
  3. /login/token/:token gated only on purpose and then granted the owner's full session regardless of the token's scope (members.js). So a LoggedInAuth child — or any scoped token carrying a login purpose — redeemed into a complete dashboard session as the owner. If the owner is a global admin, that's a global-admin session from a scoped integration token.

The loginWithToken comment even asserted arbitrary purposes are "settable via /i/token/create by a global admin" — but that endpoint uses validateUser, not validateGlobalAdmin, so any authenticated principal could set purpose: "LoggedInAuth". The premise the allowlist rested on was false.

Fix — enforce "a session comes only from a full-permission token" at both ends

  • Create side: /i/token/create authenticated via a token is allowed only if that token has no app and no endpoint restriction. api_key callers (the member) and unrestricted tokens — which is what a dashboard session token is (multi, app:"", endpoint:"") — are unaffected, so the token manager and the mint-a-login-token-and-redirect flow keep working. A scoped token is refused with 403.
  • Redeem side: /login/token/:token requires the token to be unrestricted, in addition to the purpose allowlist. Legitimate session tokens are always created unrestricted, so only scoped tokens are rejected.

Either gate alone closes the reported chain; both together enforce the invariant regardless of how a scoped login-capable token might arise (pre-existing token, a future regression, or another code path).

This is deliberately the conservative end of the planned "child permissions ⊆ the creating credential" model: refuse rather than intersect, because the app/endpoint model does not sensibly authorize management endpoints. When token/login permissions become explicit in the CRUD migration, these two checks become "you cannot grant a permission you do not hold, and login is one such permission."

Tests

7 cases in test/2.api/16.token.manager.js (run by test-api-core): restricted-token refusals for an unrestricted child, a LoggedInAuth child, and even a child scoped to its own app; plus the api_key and full-permission-token allow cases.

Reported through the security bug bounty program (received 2026-08-18).

🤖 Generated with Claude Code

ar2rsawseen and others added 2 commits August 18, 2026 19:49
…ential

A token's app/endpoint restriction scopes which app data it may read or write
through data endpoints. It does not gate /i/token/create, which carries no
app_id, so verify_token never compares the restriction there (and it is skipped
entirely when no app_id is supplied). A token "restricted to App A" therefore had
unrestricted access to token creation.

validateUser resolves any bearer token to its owner and loads the full member,
and the create handler then saved caller-supplied app/endpoint/purpose with no
relationship to the authenticating credential. So a restricted token could mint an
unrestricted child, or a LoggedInAuth token redeemable at /login/token/:token for a
full dashboard session, escalating from an app-scoped integration token to the
owner's entire account (global admin included, if the owner is one).

Gate the create handler: when authenticated via a token, allow creation only if
that token has no app restriction and no endpoint restriction. api_key callers (the
member itself) and unrestricted tokens - which is what a dashboard session token is
(multi, app "", endpoint "") - are unaffected, so the token manager and the
create-a-login-token-and-redirect flow keep working. A restricted token is refused
with 403.

This is deliberately the conservative end of the planned "child permissions are a
subset of the creating credential" model: reject rather than intersect, since the
app/endpoint model does not sensibly authorize management endpoints.

Tests: 7 cases in test/2.api/16.token.manager.js covering the restricted-token
refusals (unrestricted child, LoggedInAuth child, refused even when it supplies its
own app_id) and the api_key / full-permission-token allow cases.

Reported through the security bug bounty programme (received 2026-08-18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Defence in depth for the token scope-escalation chain. /login/token/:token gated
only on the token's purpose, then granted the owner's full session regardless of
the token's app/endpoint scope (and it carries no app_id, so verify_token's app
check is skipped there too). So any scoped token that carried a login purpose could
still be redeemed for a full session.

Require the token to be unrestricted (no app and no endpoint scope) before
establishing a session, in addition to the purpose allowlist. Legitimate session
tokens are always created unrestricted (setLoggedInVariables, the renderer's
LoginAuthToken, the ban-warning mail), so only scoped tokens are rejected.

Together with the create-side gate, this enforces the invariant from both ends: a
session grants the owner's full identity, so it may only come from a full-permission
token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ar2rsawseen

Copy link
Copy Markdown
Member Author

Closing in favor of a broader token-model rework that addresses this at the root rather than with the two containment checks here. The branch is retained as a fallback and can be reopened if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant