Commit 339576f
fix: security & auth hardening — 11 bug-hunt findings (batch:p3-security-auth) (#464)
* fix(auth): decouple reset-request timing from account existence (discogsography-0lof)
Defer the reset-token mint, Redis setex, and outbound notification-send to
a FastAPI background task scheduled unconditionally on both branches, so
the HTTP response for /api/auth/reset-request returns after nothing but
the initial SELECT regardless of whether the account exists. Previously the
Redis write + Resend HTTP round-trip only fired on the existing-account
branch, giving a reliable timing oracle that defeated the endpoint's
declared anti-enumeration contract.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): stop logging registrant/admin email addresses at INFO (discogsography-1385)
Fix-one-fix-all sweep of every logger.*(... email=...) structlog binding in
api/routers/auth.py, api/routers/admin.py, and api/notifications.py:
registration and login success logs now bind user_id instead of the raw
email; admin login logs admin user_id; DLQ-purge audit log binds admin_id
instead of admin_email; the LogNotificationChannel fallback and
ResendNotificationChannel drop the email argument from all log calls
entirely (no user_id available in that scope). No PII-bearing structlog
event remains in these files.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(api): app-token defense-in-depth compare against the real stored hash (discogsography-osoc)
_lookup_active_token now projects the row's own persisted token_hash (and
joins users to filter is_active — laying groundwork for
discogsography-ci4a) instead of only id/user_id/name/scope. Both app-token
entry points — app_tokens.require_app_token and
dependencies.require_user_or_app_token — now compare_digest the row's
stored token_hash against the lookup hash, replacing a vacuous
hash_token(plaintext) == hash_token(plaintext) self-comparison that could
never fail. dependencies.require_user_or_app_token previously had no such
check at all; the two app-token paths were diverging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): bulk-revoke app tokens on password reset/change (discogsography-ci4a)
reset_confirm and change_password now also execute UPDATE app_tokens SET
revoked_at = NOW() WHERE user_id = ... AND revoked_at IS NULL in the same
round trip as the password update. The password_changed:{user_id} Redis
marker only gates JWT validation and is sized to jwt_expire_minutes, so it
was structurally incapable of revoking app tokens (which carry no expiry
by design) — an attacker who minted a dscg_ app token from a stolen access
JWT kept read access to the victim's collection data indefinitely after
the victim 'remediated' by resetting their password. Bulk-revoking the
rows themselves closes the gap permanently, unlike a Redis-key check that
would itself expire.
Builds on discogsography-osoc's _lookup_active_token(is_active join), which
already closes the sibling 'deactivated user keeps an authenticating app
token' gap the same finding raised.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(api): stop accepting the admin-setup password via argv (discogsography-dir0)
Remove --password entirely from the admin-setup CLI. Command-line arguments
are world-readable via /proc/*/cmdline for the life of the process (ps aux,
docker container top) and land verbatim in shell history — an
unconditional leak of the highest-privilege credential in the system on
every invocation, with no non-leaking path previously available.
The password now resolves via ADMIN_PASSWORD / ADMIN_PASSWORD_FILE (the
repo's existing get_secret Docker-secrets convention, already used one line
above for POSTGRES_PASSWORD in the same file) for scripted use, falling
back to an interactive getpass.getpass() prompt (never echoed, never in
argv or history). Updated docs/admin-guide.md and docs/monitoring.md to
match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(api): key request metrics on the matched route template (discogsography-jlei)
metrics_middleware now reads request.scope['route'] AFTER call_next and
records normalize_path(route.path) — the route's template (e.g.
/api/artists/{id}) — instead of the raw, attacker-controlled URL. Requests
that never matched a route collapse into a single '<unmatched>' bucket.
Previously an unauthenticated flood of distinct nonexistent paths (each one
individually 404ing) had unbounded cardinality against the 10k-entry
MetricsBuffer, evicting every real endpoint's latency/error samples in a
single burst and bloating the persisted endpoint_stats JSONB with bogus
keys.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): make the 2FA verify lockout gate atomic with the increment (discogsography-vjod)
twofa_verify's lockout check now runs inside an explicit transaction that
takes a row lock (SELECT ... FOR UPDATE) on the user row, held across both
the lockout read and the failed-attempt increment. Concurrent verify
requests for the same account now serialize on that lock instead of all
reading a stale pre-increment snapshot — previously N requests whose
SELECT landed before the first lock UPDATE committed each got a free TOTP
guess, letting a distributed burst exceed the intended 5-attempt-per-window
cap. Branch ordering (not-configured -> locked -> encryption-not-configured
-> invalid-code) is preserved exactly; only the lockout gate's concurrency
semantics change.
tests/api/conftest.py: mock_conn now stubs set_autocommit and a no-op
transaction() async context manager so mocked tests can exercise code that
opens an explicit transaction.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): reset TOTP lockout state on successful 2FA recovery (discogsography-cflq)
twofa_recovery's code-redemption UPDATE now also sets totp_failed_attempts
= 0, totp_locked_until = NULL, mirroring the reset twofa_verify's success
path already performs. Recovery (password + one-time recovery code) is an
equally strong proof of account control as a correct TOTP code, but its
success path previously left stale lockout state untouched — a user who
fat-fingered TOTP into a 15-minute lock, then recovered successfully via a
recovery code, would still get 429 'Account temporarily locked' on their
very next CORRECT TOTP login for the remainder of that window, burning
another scarce recovery code to work around it. Folded into the existing
guarded UPDATE so it only fires when the code actually matched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): check the atomic getdel result in 2FA recovery (discogsography-kqw4)
twofa_recovery now captures the return value of the challenge-consuming
getdel and raises 401 'Challenge expired or already used' when it's falsy
— mirroring twofa_verify's identical, already-tested check. Previously the
result was discarded, so two concurrent requests carrying the SAME
challenge token but two DIFFERENT recovery codes could both redeem a code
and both mint an access token, silently voiding the challenge's one-time
contract that twofa_verify enforces. Ordering is unchanged (challenge
consumed only AFTER the recovery code is redeemed), so a mistyped code
still never burns the challenge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(auth): guard the 2FA-confirm enable UPDATE on the verified secret (discogsography-8vlp)
twofa_confirm's enable UPDATE now binds AND totp_secret = %s using the exact
encrypted secret read (and whose code was verified) earlier in the handler,
and treats rowcount 0 as 409 'setup state changed — restart 2FA setup'.
Previously the UPDATE was an unconditional blind write: if a concurrent
twofa_disable committed between confirm's SELECT and this UPDATE, the row
would land as totp_enabled=TRUE with totp_secret/totp_recovery_codes NULLed
— login demands 2FA but neither twofa_verify nor twofa_recovery can ever
satisfy it, a permanent lockout with no self-service recovery path. Mirrors
the guarded-UPDATE treatment twofa_setup already has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
* fix(api): self-heal snapshot quota counter TTL and refund on failed save (discogsography-7639)
SnapshotStore.save now calls EXPIRE ... NX on every save (not just when
count == 1), so a counter left TTL-less by a prior lost EXPIRE call (crash/
timeout right after INCR) self-heals on the next save instead of becoming
permanent — previously the TTL had exactly one arming opportunity per key
generation, silently converting a 28-day sliding window into an unbounded
lifetime counter and eventually locking the user out of snapshots forever.
The final Redis set() is now wrapped so a failure decrements the
just-reserved quota slot before re-raising, mirroring the existing
quota-exceeded decrement path — a transient Redis/network failure on that
call no longer permanently burns quota with zero live snapshots to show
for it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BnH7JWqGK1b8EuYRxYJtU
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>1 parent 8871b9b commit 339576f
20 files changed
Lines changed: 1006 additions & 139 deletions
File tree
- api
- routers
- docs
- tests/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
6 | 13 | | |
7 | 14 | | |
8 | 15 | | |
9 | 16 | | |
10 | 17 | | |
| 18 | + | |
11 | 19 | | |
12 | 20 | | |
13 | 21 | | |
| |||
64 | 72 | | |
65 | 73 | | |
66 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
67 | 90 | | |
68 | 91 | | |
69 | 92 | | |
70 | 93 | | |
71 | 94 | | |
72 | 95 | | |
73 | 96 | | |
74 | | - | |
75 | 97 | | |
76 | 98 | | |
77 | 99 | | |
78 | 100 | | |
79 | | - | |
| 101 | + | |
80 | 102 | | |
81 | 103 | | |
82 | 104 | | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | 105 | | |
88 | 106 | | |
89 | 107 | | |
90 | 108 | | |
91 | | - | |
92 | | - | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
93 | 117 | | |
94 | 118 | | |
95 | 119 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
375 | 375 | | |
376 | 376 | | |
377 | 377 | | |
378 | | - | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
379 | 390 | | |
380 | 391 | | |
381 | | - | |
382 | 392 | | |
383 | 393 | | |
384 | 394 | | |
| 395 | + | |
| 396 | + | |
385 | 397 | | |
386 | 398 | | |
387 | 399 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
172 | 172 | | |
173 | 173 | | |
174 | 174 | | |
175 | | - | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
176 | 184 | | |
177 | 185 | | |
178 | 186 | | |
| |||
181 | 189 | | |
182 | 190 | | |
183 | 191 | | |
184 | | - | |
185 | | - | |
186 | | - | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
187 | 196 | | |
188 | 197 | | |
189 | 198 | | |
| |||
222 | 231 | | |
223 | 232 | | |
224 | 233 | | |
225 | | - | |
226 | | - | |
227 | | - | |
228 | | - | |
229 | | - | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
230 | 242 | | |
231 | 243 | | |
232 | 244 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
132 | 133 | | |
133 | 134 | | |
134 | 135 | | |
135 | | - | |
| 136 | + | |
| 137 | + | |
136 | 138 | | |
137 | 139 | | |
138 | 140 | | |
139 | 141 | | |
140 | 142 | | |
141 | 143 | | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
142 | 154 | | |
143 | 155 | | |
144 | 156 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
28 | | - | |
29 | | - | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
61 | | - | |
| 61 | + | |
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | | - | |
| 75 | + | |
76 | 76 | | |
77 | | - | |
| 77 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
121 | | - | |
| 121 | + | |
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| |||
593 | 593 | | |
594 | 594 | | |
595 | 595 | | |
596 | | - | |
597 | | - | |
| 596 | + | |
| 597 | + | |
598 | 598 | | |
599 | 599 | | |
600 | 600 | | |
| |||
0 commit comments