Skip to content

fix(security): re-derive JWT admin role and scope agent tokens per owner - #1183

Merged
Dumbris merged 5 commits into
mainfrom
claude/sec-1168-1169-server-edition-auth
Sep 2, 2026
Merged

Dumbris merged 5 commits into
mainfrom
claude/sec-1168-1169-server-edition-auth

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 2, 2026

Copy link
Copy Markdown
Member

Closes #1168, #1169.

Server-edition auth. All changes are behind //go:build server; the personal edition is behaviourally unchanged, and there is no storage migration — existing on-disk tokens resolve as before.

#1169 — admin demotion did not take effect until token expiry

The session path re-derived the admin role from admin_emails on every request; the bearer-JWT path trusted claims.Role verbatim. Removing someone from admin_emails left them with full admin — every AdminOnly() route and CanRevealSecrets() — until their JWT expired, by default 24 hours. The JWT path now re-derives the role the way the session path does, with field parity proven rather than assumed.

#1168 — token names were a global namespace, and AuthContext() dropped UserID

Two tenants could not both have a token named ci, and the revoke path's 403-vs-404 split was a name-probing oracle. Separately, AuthContext() copied every field except UserID, so an agent-token request carried no tenant identity — under a constructor whose own doc comment says it exists "so no auth path can silently drop a field".

Name resolution is now owner-scoped, and unowned and absent are indistinguishable on revoke, delete and regenerate. The TOCTOU preflight is gone in favour of classifying the mutator's own error, which closes the window and a leaked sentinel string together.

A proposed one-time migration sweep was dropped: its justification — that a rolled-back binary could resolve ci to the wrong tenant — is false, since every user-facing by-name path already re-checks ownership after the lookup and denies. The sweep bought nothing and its rollback behaviour would have stranded existing tokens.

Found while fixing these

  • Carrying UserID introduced a regression. multiuser.Router.BrokeredConnectionKey keyed the connection pool off GetUserID() with no IsUser() guard, so an agent token would pool onto its owner's brokered upstream connection — carrying the owner's injected IdP credential. Previously it keyed as anonymous. Guarded, plus a sweep of every other GetUserID() consumer. (multiuser.Router has no production caller yet; the guard is correct for when it is wired, and the comments now say so instead of resting on it.)
  • allowed_servers was accepted verbatim on POST /api/v1/user/tokens, with no intersection against the tenant's entitled set — so a user could mint ["*"] and walk straight through the REST server enumeration ignores agent-token scope: /api/v1/servers and /api/v1/config list every server regardless of allowed_servers #1166 scope filter. Now intersected, read live from the config rather than a boot-time snapshot, which was itself the first fix's blind spot.
  • A disabled user's agent tokens kept authenticating. Disabling an account is the documented remediation for a compromised user, and it did not revoke their minted credentials. Now fail-closed via an owner gate, with revocation on the disable path.

Follow-ups filed, deliberately not bundled

Review

Two opencode (gpt-5.6-sol) rounds, both BLOCKING; 14 genuine findings closed, 1 false positive rejected with evidence. 27 tests. The oracle tests carry a positive control on the same router and pin exact status values, rather than asserting bare parity — a sibling test in this package passes today only because a nil store makes both answers 500.

… scope agent-token names per tenant

Two server-edition auth defects.

#1169 — the bearer-JWT path trusted the frozen `role` claim while the session
path re-derived the role from `admin_emails` on every request. Removing someone
from `admin_emails` therefore did not demote them: they kept every AdminOnly
route and CanRevealSecrets() until their JWT expired — and the bug did not even
self-heal at expiry, because POST /api/v1/auth/token mints a fresh JWT from
ac.Role, so an active holder could renew admin indefinitely.

authenticateFromBearer now calls buildAuthContext(user) instead of branching on
claims.Role. Identity parity is exact: GetUser is keyed by User.ID (so
user.ID == claims.Subject) and both mint sites pass user.ID/user.Email/
user.DisplayName/user.Provider — the same four values buildAuthContext reads,
only fresher. Promotion works symmetrically, matching the session path; a
demote-only AND of claim and config would strand promoted users and is pinned
against by a test. Note this is not hot-reloadable: the middleware captures the
config pointer at wiring time, so an admin_emails edit lands at the next process
restart on BOTH paths, as it always did.

#1168 — (a) agent-token names lived in one global index keyed by the bare name,
so two tenants could not both have a token called `ci`, and revoke/delete/
regenerate answered 403 "not another user's token" versus 404 for an absent one,
which is a name-existence oracle over other tenants. Storage now resolves by
(owner, name) with a scan of the authoritative agent_tokens bucket, which
carries both fields; the legacy agent_token_names index is maintained only for
ownerless (personal-edition) tokens, so the personal edition is byte-identical
on disk and NO MIGRATION is needed. Handlers lost their 403 branches entirely —
"not yours" and "not there" are now the same nil and the same 404 — and the
create path no longer echoes the storage conflict, which leaked the same fact.
UpdateAgentTokenLastUsed on the auth hot path moved to a by-hash form, since a
bare name is now ambiguous.

(b) AgentToken.AuthContext() dropped UserID, so an agent-token request carried
no tenant identity at all despite the constructor's promise that no auth path
can silently drop a field. The field is added, and the surfaces that gated only
on a non-empty UserID (getUserID, getMe, generateToken) now require the user
TIER — without that, a scoped read-only agent token could mint a full user
session JWT for its owner. principalFromContext also qualifies the agent
principal with the owner, so two tenants' `ci` tokens no longer collapse into
one work session in the audit trail.

Deliberately NOT done: the one-time index sweep an earlier draft proposed. Its
premise was false — every by-name path on current main re-checks ownership after
the lookup, so a stale entry can only deny, never mutate the wrong tenant's
token — and sweeping would strand pre-upgrade tokens on a rollback.
…wner may see

POST /api/v1/user/tokens persisted the requested `allowed_servers` verbatim —
no validation of any kind, where the personal-edition twin
(internal/httpapi/tokens.go) at least checks the names are real. AllowedServers
is the sole input to auth.AuthContext.CanAccessServer, so a regular tenant
could mint themselves a token scoped to ["*"], or naming servers they have
never been shown, and walk straight through the REST enumeration scope filter
into the admin's whole inventory. NewUserHandlers is handed deps.Config.Servers
— the entire admin configuration, not just the shared subset — so the leak
covers servers no tenant is ever meant to know exist.

The scope is now narrowed to the per-user door's own entitlement predicate:
the caller's personal servers plus the admin servers flagged `shared`, reusing
what listServers renders rather than defining entitlement a second time.

- An unentitled name is REJECTED (400), not silently dropped: a token that
  quietly sees less than asked is worse than a refusal. The message is
  identical for another tenant's server, the admin's unshared server and a
  server that exists nowhere — distinguishing them would re-open, on server
  names, the existence oracle this branch just closed on token names.
- "*" is the only wildcard the enforcement layer honours, so there are no globs
  to expand. It stays literal for an admin, who does administer every server;
  for a tenant it is materialised into their entitled set at mint time (a
  snapshot, echoed back in the response) and refused when that set is empty.
- An omitted allowed_servers is left empty, which already denies every server
  at the agent tier. The personal edition's "empty means [*]" default is not
  copied: there the only caller is the operator.

Tests follow the branch's oracle discipline — a positive control minting an
entitled scope first on the same router, an independent entitlement proof
through GET /api/v1/user/servers, pinned 201/400 statuses rather than parity,
and storage-layer confirmation that a rejected request minted nothing.
…kens pooling onto their owner

Cross-model review findings on the #1168/#1169 branch.

H1 — the allowed_servers entitlement check was bypassable by a name collision.
createServer only refused a personal-server name colliding with a server flagged
Shared, so a tenant could create a personal server named exactly like one of the
admin's PRIVATE upstreams; entitledServerNames then added that name on the
strength of the personal server, and the minted token named the admin's server
as far as CanAccessServer is concerned — it compares bare strings and has no
notion of an owner. The same collision is a routing hazard independent of
tokens: Router.GetServerForUser matches the whole configuration before the
caller's workspace, so the name resolves to the admin's config and credentials.

The refusal now covers ANY server in the admin configuration, with a message
identical for a shared and a private collision — distinguishing them would name
the admin's private inventory to a tenant who cannot list it, reopening the
existence oracle this branch closed on token names, one shape over. A refusal
necessarily signals that a name is unavailable; it signals nothing else.
entitledServerNames independently drops a collision for a non-admin, so a row
written before the refusal existed, or an admin who later adds a colliding name,
cannot resurrect the escalation.

H2 — regression introduced by this branch. Router.BrokeredConnectionKey resolved
the FR-018 pooling key from ac.GetUserID() with no tier check. That was harmless
while only user sessions carried a UserID; #1168 made an agent token carry its
OWNER's, so an agent-token request keyed identically to the owner's session and
pooled onto the owner's brokered connection — carrying the owner's injected IdP
credential. Only a user tier now keys as its bare id; every other tier is
namespaced, which keeps two owners' agent tokens apart without ever colliding
with a session.

Sweep of the other GetUserID()/AuthContext.UserID readers, since carrying a new
field through a shared constructor changes every reader:
- multiuser/activity.go GetUserActivity gated on `UserID != ""`, which now
  admits an agent token as its owner's session. Hardened to IsUser(); the
  server-edition middleware already refuses agent tokens on that route, so this
  is defence in depth, but one middleware branch is not a boundary. EnrichRecord
  deliberately stays UserID-based — attribution is what the field is for.
- router.go GetUserServers/GetServerForUser, api/user_handlers.go getUserID,
  api/auth_endpoints.go, server/observability_edition_server.go: already gated on
  IsUser(). No change.
- server/workspace.go principalFromContext, mcp.go, preflight.go,
  mcp_describe_check.go: attribution only, and workspace.go already namespaces
  the agent tier explicitly. No change.

H3 — findAgentTokenHashLocked walks the whole bucket, so returning an error on
the first row that failed json.Unmarshal turned create/revoke/delete/regenerate
into a 500 for every tenant. Bad rows are now skipped and logged: one corrupt
record degrades to "that token is unresolvable".

H4 — revoke/delete/regenerate dropped their preflight read. It was a TOCTOU
window (a concurrent delete-then-recreate had the in-flight regenerate land on
the replacement) and its fall-through 500 interpolated the storage sentinel into
the response body. The mutator's own error is classified instead:
ErrAgentTokenNotFound -> 404 with the same body as an absent token.

H5 — CreateAgentToken blindly Put the legacy owner-blind name index, overwriting
whatever the name pointed at, including the pre-upgrade server-edition entry the
bucket comment promises to preserve for a rollback. The slot is now claimed only
when free or dangling, matching what delete and regenerate already did.

H6 — a token's server scope was decided once at mint time and never re-checked,
so un-sharing a server does not revoke live grants. Regenerate now re-runs the
entitlement predicate and persists the narrowed list (narrowing only, never a
refusal: refusing to rotate would leave the over-broad grant in place and
working). Documented, including what to do about tokens minted with a literal
"*" before this branch.

H7 — the token cap answered 409 on the personal surface and 503 here. Now 409 on
both: one storage condition, one status, and the cap is a permanent conflict
rather than a transient outage worth retrying.

H8 — the role-freshness docs put "promotes them on their next request, without
re-login" two bullets above "not hot-reloadable", so an operator could read the
first and believe a DEMOTION is immediate. The caveat now leads.

Every fix carries a test proven to fail without it by permissive neutering, and
follows the branch's oracle discipline: positive controls on the same router,
pinned statuses, storage-level confirmation of the end state, and never
"the body does not contain the name".
…disabled owner's tokens

Seven findings from the second cross-model review.

Q1 (blocking): UserHandlers snapshotted deps.Config.Servers at construction,
so collidesWithAdminConfig and entitledServerNames were blind to any admin
server added after process start — a tenant could pre-create a personal server
named like an admin server that only appears later and walk through the
allowed_servers control exactly as before the fix. NewUserHandlers now takes an
AdminServersProvider read on every check, wired through a new
Dependencies.ConfigProvider over the runtime's atomic config snapshot. The
configuration is read once per call and threaded down, so one decision is never
split across two versions of a hot-reloadable file.

Q2: a disabled user's agent tokens kept authenticating. storage.Manager gains
an owner gate consulted by ValidateAgentToken for every owned token, installed
in setup.go over the user store and failing closed; ownerless personal-edition
tokens are never gated. disableUser additionally revokes that user's tokens, so
re-enabling the account cannot resurrect a stolen credential.

Q3: the token-cap 409 used the personal edition's wording, which reads as the
caller's own quota. auth.MaxTokens is deployment-wide, so the body now says the
limit is shared and points at an administrator (per-owner quota is #1177).

Q4: createServer answered a name held by the admin and one held by the caller
with different bodies, letting a tenant enumerate a private inventory. Both are
now one answer.

Q5: ListAgentTokens aborted on the first unparseable record, turning one
corrupt row into a 500 for every tenant; it now skips and logs, as the by-name
resolver already did.

Q6: the narrowScope narrow-only contract was a doc comment. Storage now
intersects the hook's return with the token's stored AllowedServers (a stored
"*" still materialises into a concrete list).

Q7: multiuser.Router has no production caller, so its guards are latent.
Comments that justified the collision refusal by a routing hazard now say the
escalation is the token-scope one, and the router documents that nothing may
rest on it until it is wired.
@codecov-commenter

codecov-commenter commented Sep 2, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 73.30317% with 59 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/storage/agent_tokens.go 75.00% 28 Missing and 21 partials ⚠️
internal/httpapi/tokens.go 33.33% 6 Missing ⚠️
internal/server/workspace.go 0.00% 2 Missing ⚠️
internal/httpapi/server.go 0.00% 0 Missing and 1 partial ⚠️
internal/server/server.go 66.66% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/sec-1168-1169-server-edition-auth

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 33618632999 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…rst, stop rotation resurrecting burned tokens

Closes the three MAJORs from the final cross-model review, plus two smaller
contradictions with the branch's own claims.

N1 — the #1169 fix read a BOOT-TIME config. The middleware held the
*config.ServerEditionConfig captured at wiring time, so removing someone from
admin_emails took effect only at the next process restart. admin_emails IS
hot-reloadable (LoadFromFile unmarshals server_edition whole, it is not
restart-pinned, and the watcher republishes the snapshot), so the role is now
derived through a ServerEditionConfigProvider over Dependencies.ConfigProvider —
the same provider the admin-servers check already uses, not a second mechanism.
The docs bullet is rewritten to match, including the two limits it does not
cover.

N2 — data race on the published config snapshot. handleAddUpstream appended to
runtime.Config().Servers in place; this branch installs a per-request reader over
that slice. Both append sites now go through one copy-on-write helper.

N3 — the agent-token owner gate was installed after four fallible steps, and
SetupAll's error is only logged, so the server could come up with a disabled
user's tokens ungated. It is now the first thing setupMultiUserOAuth does, so a
failure leaves it in force and failing closed.

N4 — RegenerateAgentTokenForOwner cleared Revoked, letting an owner un-revoke a
token disableUser had deliberately burned. Rotation now refuses a revoked token
(ErrAgentTokenRevoked, 409 on both editions' surfaces).

N5 — regenerate repointed the legacy name index unconditionally for ownerless
tokens, stomping the pre-upgrade entry the no-migration rollback rationale
promises to preserve. It now applies create's claim rule.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: bbfe39d
Status: ✅  Deploy successful!
Preview URL: https://d5be4f03.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-sec-1168-1169-server.mcpproxy-docs.pages.dev

View logs

@Dumbris
Dumbris merged commit 07ea246 into main Sep 2, 2026
40 checks passed
Dumbris added a commit that referenced this pull request Sep 2, 2026
…sory (#1189)

The server edition has no meaningful install base, so the section was noise for
every reader of the notice. Cutting it keeps the advisory focused on what a
personal-edition user has to act on, which is the whole reason for pinning a
hand-written notice above the generated notes in the first place.

The fixes themselves still ship in this release - #1168 and #1169 are merged in
#1183 and remain linked from the changelog and their issues, so a server-edition
operator can still find them.
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.

Server edition: agent-token names are a global namespace and AuthContext() drops UserID, so a tenant's token carries no user identity

2 participants