fix(access-review): verify the 2FA filter, and purge stale TOTP rows on rotation #431
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-infra!431
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "fix/access-review-verify-2fa-filter"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Two of the four prevention items on #292. Two commits, unrelated to each other except by that
issue.
1. The access review verified its own filter
access-review-2fa.pyderives the whole 2FA column from a query parameter, and Forgejo ignoresquery parameters it does not recognise rather than rejecting them.
So if
is_2fa_enabledis ever not honoured, the filtered call returns every user and the reportmarks the entire estate as 2FA-enabled. The failure is invisible in the output: a clean, confident,
entirely false access review, submitted as ISO 27001 evidence for bitborg-docs#30.
Both halves are now fetched and checked against the unfiltered list. They must partition it exactly,
no account in both and none in neither. Otherwise the script exits with the counts rather than
rendering a fabricated column.
What prompted it: the swagger checked into the workspace is
15.0.3+gitea-1.22.0whileproduction runs v16, and it documents no
is_2fa_enabledparameter and no 2FA endpoints at all.The filter is probably fine on v16. But "probably fine" and "verified" are different claims for
something that goes into an audit, and my read-only token could not settle it either way.
Covered by a test inducing three cases:
2. Purge stale TOTP rows as part of rotating
The rotation section enumerated stale enrolments but left purging them as a later exercise, so a
rotation could complete leaving a live lockout for whoever signs in next. Adds the
DELETEalongside the
SELECT.Adds a pre-rotation guard that was missing entirely: there is no API to reset 2FA, only
forgejo admin user reset-mfaon the host or the admin web UI, and that UI sits behind the very2FA the rotation breaks. Without a non-TOTP factor on at least one admin, a rotation can lock out
the only account able to repair it.
Records what survives, verified against the v16.0.2 source rather than assumed.
keyingappearson three lines of
models/auth/twofactor.go,VerifyScratchTokenis plain PBKDF2, andmodels/auth/webauthn.gohas no keying call. So scratch codes, security keys, PATs, SSH keys andre-enrolment all still work; only the TOTP secret is lost. The existing triage treats the account as
fully locked out, which overstates it and hides the ways back in.
Also notes that over HTTP Basic the breakage surfaces as a 401, not a 500, so a git-over-HTTPS
report reads as bad credentials; and that rotation is unsupported upstream and unscheduled, with the
issue numbers, so nobody waits for a fix that is not coming.
What is NOT in this PR, and why
#292's remaining item asks the access review to flag native 2FA enrolments predating the current
SECRET_KEY. That is not implementable through the admin API:Usermodel exposes no 2FA field at all;The timestamp exists only in the database (
two_factor.created_unix), which is why the runbook'sprocedure is a
psqlquery rather than a script. Building a weaker API-based approximation andcalling the box ticked would be worse than saying so. The runbook change above covers the same
ground operationally.
The other two items are decisions rather than code: whether to prevent native 2FA enrolment
outright, and whether to report the 500 upstream. Left for #292.
Verification
./scripts/ruff.shclean (pinned container).pnpm mdlintandpnpm format:checkclean.collect()against injected API responses.872a83d2e528353e9f63