Skip to content

orb(auth): fleet-operator trust is a login string while the session's immutable githubUserId is stored and never checked — a released GitHub handle grants operator access #9126

Description

@JSONbored

Problem

Fleet-operator authorization is decided by a login string, while the session's immutable
githubUserId is captured, stored in the schema, and never consulted for any authorization decision.

authenticateRequestIdentity returns the login as the actor (src/auth/security.ts:135):

return { kind: "session", actor: session.login, session };

and the operator check compares that string against the configured allowlist (:138-142):

export function isAuthorizedGitHubSessionLogin(env: Env, login: string): boolean {
  const allowedLogins = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS);
  if (allowedLogins.size === 0) return false;
  return allowedLogins.has(login.toLowerCase());
}

githubUserId is persisted — createSessionForGitHubUser writes githubUserId: user.id
(security.ts:316) into github_user_id (src/db/schema.ts:1070). Its only read sites echo it back in
an API response (src/api/routes.ts:5380-5381) and shuttle it through the repository layer
(src/db/repositories.ts:1882, 7344). No authorization path reads it.

canSessionAccessPath short-circuits on this login check as its very first line, bypassing every
per-repo guard below it. In self-host default mode isPerTenantAdmin collapses to the same membership
test.

Trigger

An ADMIN_GITHUB_LOGINS entry belongs to a former co-maintainer who renames or deletes their GitHub
account. GitHub releases an abandoned handle for re-registration. Anyone who registers that handle signs
in through /v1/auth/github, receives a session with login = <released handle>, and
isAuthorizedGitHubSessionLogin returns true.

Impact

Full fleet-operator compromise from a username the organisation no longer controls: the operator API
surface plus every requireRepoMaintainer / requireRepoWriteAccess guard, bypassed at the first line.
It also reaches the disposition planner — authorIsAdmin (src/queue/processors.ts ~2800, 3067, 5703)
exempts the login from auto-close and, together with closeOwnerAuthors, changes merge eligibility.

This is a latent-but-cheap compromise: it requires no vulnerability, only patience and a lapsed handle.

Requirements

  • Add ADMIN_GITHUB_IDS (or resolve each ADMIN_GITHUB_LOGINS entry to its numeric id once at boot,
    cached) and require session.githubUserId to match. Fall back to login-only when no id is configured,
    so existing deployments keep working, and log a startup warning naming the weaker posture.
  • Apply the same treatment to autoCloseExemptLogins and closeOwnerAuthors if they gate anything
    privileged.
  • Follow the pattern verifyInstallationAdmin (src/orb/oauth.ts ~100) already sets — it correctly binds
    to the immutable account_id for both the User and Org arms. This is the one place in the codebase that
    gets identity right; make it the rule rather than the exception.
  • Document the id-vs-login posture in the self-host security notes so operators know which they configured.

Test Coverage Requirements

99%+ patch coverage, branch-counted. Both arms of id-configured / id-absent, plus a regression test that a
matching login with a non-matching id is denied.

Links & Resources

Boundaries

Operator/session identity binding only. No change to the session lifecycle, cookie handling, or the OAuth
flow itself (which is otherwise sound — signed+HMAC'd state with expiry, double-submit cookie, origin
allowlist on returnTo).

maintainer-only — authentication authority.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions