Throttle the change-password route and make epoch bumps atomic

Two session-management bugs from the audit:

- /api/password recorded failed attempts against the login budget but never
  checked it, so a borrowed session could guess the current password without
  limit while still locking the owner out of /api/login. Verified: thirteen
  consecutive wrong guesses all returned 403 and none returned 429. It now
  spends from the same budget it was topping up.

- bump_epoch read the epoch and wrote it back without the write lock, so
  concurrent revocations lost increments and sessions that should have been cut
  off survived. Verified: twenty concurrent bumps advanced the counter from 2 to
  6, and twenty concurrent "sign out other devices" calls left four sessions
  authenticated. It takes BEGIN IMMEDIATE now; set_password hashes before
  locking, so scrypt doesn't serialise unrelated writes.

Removing either fix makes its test fail with exactly that symptom.

README corrections: deployment is rsync, not git pull — gitea on .8 cannot
serve a clone to .8 itself, which the deploy section now documents — and the
concurrency check count was understated.

Checks go from 182 to 197.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 11:04:52 -04:00
parent ab82b5e9a9
commit d76642f85d
5 changed files with 183 additions and 30 deletions
+9 -1
View File
@@ -115,7 +115,11 @@ def check_password(conn, candidate: str) -> bool:
def set_password(conn, new_password: str):
"""Store a new password and invalidate every outstanding session."""
db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
# Hash before taking the lock: scrypt is deliberately slow, and holding
# SQLite's write lock across it would serialise unrelated writes.
hashed = hash_password(new_password)
db.begin_immediate(conn)
db.set_setting(conn, PASSWORD_KEY, hashed)
bump_epoch(conn)
@@ -141,6 +145,10 @@ def current_epoch(conn) -> int:
def bump_epoch(conn) -> int:
# Read-modify-write, so it needs the write lock across both halves. Without
# it, concurrent "sign out other devices" calls read the same epoch and
# overwrite each other, and sessions that should have been cut off survive.
db.begin_immediate(conn)
epoch = current_epoch(conn) + 1
db.set_setting(conn, EPOCH_KEY, epoch)
return epoch
+10
View File
@@ -367,6 +367,16 @@ async def change_password(
if not auth.auth_enabled():
raise HTTPException(400, "Authentication is disabled, so there is no password to change")
# This route verifies the same credential the login form does, so it has to
# honour the same budget. It was recording failures without checking them,
# which meant a borrowed session could guess the password without limit —
# while still locking the owner out of /api/login.
retry_after = auth.login_retry_after()
if retry_after:
raise HTTPException(
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
)
if not auth.check_password(conn, body.current_password):
auth.record_failure()
await asyncio.sleep(0.5)