Fix dashboard login on installs with 2FA enabled - #343
Merged
Conversation
Two defects made the dashboard unreachable on any instance with 2FA on,
found on a self-hosted instance whose 2FA had been enabled.
1. totp.js stored the pending-2FA token in oauth_tokens, whose
CHECK(token_type IN ('access','refresh')) rejects token_type
='pending_2fa'. attemptLogin's DB-failure path swallowed the error, so
a CORRECT password rendered "Login temporarily unavailable (server
database error)" and there was no way in from the UI. Instances with
2FA off never hit it, because sessions use token_type='access'.
Fixed additively with a dedicated dashboard_pending_2fa table rather
than widening the CHECK: SQLite cannot ALTER a CHECK, and the
alternative was a DROP/recreate of a live token table. init-db creates
it; runGatewayMigrations also creates it at boot so a host that pulls
and restarts without running init-db is not left locked out (the same
deploy-ordering window ensureSyncConflictsOpColumn closes).
2. dashboard/index.js called t() at 17 sites without importing it. Every
one of those paths - login lockout, 2FA session expiry, invalid 2FA
code, 2FA recovery, 2FA setup errors, password-reset errors - threw
ReferenceError: t is not defined, and because an unhandled rejection
is fatal there, each one took the whole gateway process down. Five
consecutive wrong passwords were enough to crash-loop the gateway.
Tests: tests/dashboard-2fa-login.test.js covers the token round-trip
(hashed at rest, context read without consuming, single-use verify,
expiry sweep), asserts the pre-fix INSERT still fails the CHECK so the
reason for the new table stays documented, and adds two static
rot-guards - no totp.js SQL may name oauth_tokens, and index.js must
import every i18n helper it calls. Both guards were confirmed to fail
against the unfixed files.
kh0pper
added a commit
that referenced
this pull request
Sep 10, 2026
…#345) A POST to /dashboard/login/2fa with the `totp_code` field simply absent reached verifyTotp(undefined, secret). otpauth reads `.length` off the token, so the call raised `TypeError: Cannot read properties of undefined` inside an async route handler — an unhandled rejection, which is fatal, so one malformed request took the whole gateway process down. Same class as the t()-not-imported defect in #343: a bad request must render an error, never end the process. The secret argument is attacker-shaped too. /dashboard/login/2fa/setup and the settings enable_2fa action both pass a `secret` straight from the request body, and Secret.fromBase32(undefined) throws the same way, as does a string that is not base32 at all. So verifyTotp is now total on both arguments: a non-string or blank token, a non-string or blank secret, and a secret that will not parse as base32 each answer false. Nothing else changes — a number, an object or a junk string already answered false via otpauth's own null return, and a real code for the current period still verifies. Found on the R4 instance while building a client for the perch-interactive routes; one malformed POST restarted its gateway during #343's own verification. Co-authored-by: kh0pper <kevin.hopper@maestro.press>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dashboard login is unreachable on any instance with 2FA enabled. Two independent defects, both on error/edge paths, which is why they survived: an instance with 2FA off never reaches either.
1. The pending-2FA token could never be stored
dashboard/totp.jswrote the pending-2FA token intooauth_tokens, whose constraint isCHECK(token_type IN ('access','refresh')). Every 2FA login failed withCHECK constraint failed: token_type IN ('access', 'refresh').attemptLogin's DB-failure path reports that as a soft error, so a correct password renders "Login temporarily unavailable (server database error)" with no route in from the UI.Fixed additively with a dedicated
dashboard_pending_2fatable rather than widening the CHECK. SQLite cannotALTERa CHECK constraint, so widening means DROP/recreate/copy of a live token table; the house pattern for that (thebot_sessions.controlwiden) also needs aSCHEMA_GENERATIONbump, aREBUILD_TABLESentry and the dry-run script. A new table gets the same result with nothing dropped. No data migration is needed, because nopending_2farow can ever have been written.init-db.jscreates the table, andrunGatewayMigrationsalso creates it at boot, so a host that pulls and restarts without running init-db is not left locked out. That is the same deploy-ordering windowensureSyncConflictsOpColumnalready closes.2. A wrong password could crash the gateway
dashboard/index.jscallst()at 17 sites but never imported it. Every one of those paths throwsReferenceError: t is not defined:An unhandled rejection is fatal in that process, so each one takes the gateway down. Observed live: five wrong passwords, then every subsequent attempt crash-looped the service (restart counter climbing,
Main process exited, code=exited, status=1/FAILURE). The i18n keys themselves all exist; only the import was missing.Tests
tests/dashboard-2fa-login.test.js:INSERTstill fails the CHECK, so the reason the new table exists stays documentedsql:line intotp.jsmay nameoauth_tokensindex.jsmust import every i18n helper it calls, and every key it asks for must resolveBoth rot-guards were run against the unfixed files and confirmed to fail (missing import
tdetected; 5 offending SQL lines detected).migration-guard,migration-registry,auth-networkandlogin-password-toggleall still pass (52 + 9 tests). The init-db change is purely additive, so the destructive-statement rot-guard is unaffected.Deploy note
Existing installs get the table from either path (init-db or the next gateway boot). An instance currently locked out by defect 1 becomes loggable after a restart on this code.