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.
Problem
Fleet-operator authorization is decided by a login string, while the session's immutable
githubUserIdis captured, stored in the schema, and never consulted for any authorization decision.authenticateRequestIdentityreturns the login as the actor (src/auth/security.ts:135):and the operator check compares that string against the configured allowlist (
:138-142):githubUserIdis persisted —createSessionForGitHubUserwritesgithubUserId: user.id(
security.ts:316) intogithub_user_id(src/db/schema.ts:1070). Its only read sites echo it back inan 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.canSessionAccessPathshort-circuits on this login check as its very first line, bypassing everyper-repo guard below it. In self-host default mode
isPerTenantAdmincollapses to the same membershiptest.
Trigger
An
ADMIN_GITHUB_LOGINSentry belongs to a former co-maintainer who renames or deletes their GitHubaccount. GitHub releases an abandoned handle for re-registration. Anyone who registers that handle signs
in through
/v1/auth/github, receives a session withlogin = <released handle>, andisAuthorizedGitHubSessionLoginreturns true.Impact
Full fleet-operator compromise from a username the organisation no longer controls: the operator API
surface plus every
requireRepoMaintainer/requireRepoWriteAccessguard, 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
ADMIN_GITHUB_IDS(or resolve eachADMIN_GITHUB_LOGINSentry to its numeric id once at boot,cached) and require
session.githubUserIdto 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.
autoCloseExemptLoginsandcloseOwnerAuthorsif they gate anythingprivileged.
verifyInstallationAdmin(src/orb/oauth.ts~100) already sets — it correctly bindsto the immutable
account_idfor both the User and Org arms. This is the one place in the codebase thatgets identity right; make it the rule rather than the exception.
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
src/auth/security.ts~130-142, ~181, ~316, ~335;src/api/routes.ts~6231, 6496;src/mcp/server.ts~3946;src/services/control-panel-roles.ts~30, 67;src/db/schema.ts~1070(miner detection). All three share the root cause "login used where an id is available".
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.