Require email_verified and make first-admin bootstrap run once #60
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH
The bug
backend/app/api/routes/auth.py:49,60trusts theemailclaim without checkingemail_verified— the string appears nowhere in the codebase — and uses it to grant admin:Two problems:
attacker registers claiming the address in
CIRCA_FIRST_ADMIN_EMAILSand is promoted onfirst login. Email is also a
uniquecolumn, so an unverified address can squat an identity.the first. An attacker who gains control of a matching address at any point in the future
gets admin retroactively.
Authentik can be configured to require verification, so this may not be currently exploitable —
but the application should not depend on an external system's configuration for a privilege
grant this significant.
Fix
userinfo.get("email_verified") is Truebefore the email is used for anything —bootstrap, identity matching, or storage. Reject the login with a clear message otherwise.
or only while zero admins exist in the database.
Done when
References
backend/app/api/routes/auth.py:49,60backend/app/config.py(first_admin_email_set)Fixed in
4333b5e. CI green.Two changes.
email_verifiedis now required. The email claim drives both the allowlist (#56) and the adminbootstrap, so it is rejected with 403 unless the provider marks it verified. Authentik can be
configured to require verification, but the application should not depend on an external system's
configuration for a privilege grant this significant.
The bootstrap window now closes. It only ever applied "on first login" in name — the check ran on
every login, so anyone who later gained control of a listed address was promoted retroactively,
and an admin demoted through the UI would be silently re-promoted next time they signed in. It now
requires both a newly created account and
not admin_exists().One deliberate subtlety:
admin_exists()filters onis_active, so a deactivated sole adminreopens the window. Otherwise deactivating the only admin would permanently lock the deployment
out of its own administration with no recovery path short of editing the database.