fix(security): stop an audit write failure disabling account lockout - #227
Merged
Conversation
Failed attempts were counted by querying auth_events, whose writes swallow every error. Any condition that degraded audit writes while leaving the service running stopped failures being counted, so lockout silently stopped enforcing on every account while authentication carried on. Disk exhaustion, a table lock, a failed migration or connection pool exhaustion would all do it, and the absent records are the same absent records that would have shown it happening. The practical difference was a bounded versus an unbounded guessing attack against a numeric OTP code. Failed attempts now go to auth_failures, written separately from the audit event and read only by the lockout policy, so losing the trail no longer loses the control. Reproduced against a real database by renaming auth_events out from under a running instance: ten failures still counted, the account still locked, and the instance reported degraded. getUserLockoutStatus refuses rather than guessing when it cannot read the counter. An authentication the server cannot vouch for gets the same 423 a locked account gets, instead of being admitted because the count came back empty. Audit write failures are reported where monitoring already looks. GET /health/status answers 200 with a degraded block for five minutes after one. The healthy body is unchanged and the status stays 200, because the service is still serving and should not leave a load balancer over this. That is the defined action NIST 800-53 AU-5 asks for; a log line nobody reads is not. Audit writes themselves still do not throw. 137 call sites await them, many from inside error handlers, so failing there would turn a bookkeeping failure into a failed request, including on the paths that report problems. Closes #211
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.
Closes #211, and fixes the private advisory it points at (GHSA-2r8w-h4h9-jw8p,
high).
The vulnerability
Failed attempts were counted by querying
auth_events, whose writes swallow everyerror. The control and its telemetry were one data path, and that path failed open
at both ends: any condition that degraded audit writes while leaving the service
running stopped failures being counted, so lockout silently stopped enforcing on
every account while authentication carried on. Disk exhaustion, a table lock, a
failed migration or pool exhaustion all qualify, and the absent records are the
same absent records that would have shown it happening.
Reproduced against a real database by renaming
auth_eventsout from under arunning instance, then re-run against this branch:
Before, that same scenario counted zero and left the account open.
What changed
Failed attempts have their own table.
auth_failuresis written separatelyfrom the audit event and read only by the lockout policy, so losing the trail no
longer loses the control. The write is hooked in
AuthEventService.logContext, sonone of the 137 call sites change.
An unknown count means locked.
getUserLockoutStatusrefuses rather thanguessing when it cannot read the counter, returning the same
423a lockedaccount gets.
Failures are reported where monitoring already looks.
GET /health/statusanswers
200 { "message": "System up, audit degraded", "degraded": { "audit": … } }for five minutes after a failed audit write. That is the defined action AU-5asks for; a log line nobody reads is not.
A correction to the advisory
The advisory's fourth remediation item says the
|| 0coalescing "also yieldszero" when the count query throws, which would read as not locked. That part does
not hold:
Number(await count()) || 0propagates a rejection rather thancoalescing it, so a throwing query surfaced as a
500, not a bypass. I checkedbefore repeating it.
The core finding is unaffected and remains correct. The bypass came from the
swallowed writes, which meant the count legitimately returned zero, not from
the coalescing. I have still removed the coalescing and made an unreadable count
fail closed, because a
423is the honest answer there and a500was not, butit should not be recorded as the mechanism.
Deliberate non-changes
Audit writes still do not throw. 137 call sites await them, many from inside
error handlers, so failing there would turn a bookkeeping failure into a failed
request, including on the paths that report problems. The trail is best effort by
design; what changed is that nothing security-relevant depends on it and the
failure is visible. The posture is written down in
docs/security-posture.mdrather than left implicit, and a configurable fail-closed authentication mode is
noted there as not available today.
auth_failuresrows are not pruned, which matchesauth_events. Retention is#173, and adding a second retention story here would pre-empt that decision.
No counter reset on success. The window handles expiry, as it did before.
Clearing on a successful sign-in would let a legitimate login wipe an attacker's
accumulated count, which is a behaviour change worth making deliberately rather
than as a side effect of this fix.
Contract
GET /health/statusgains an optionaldegradedobject. The healthy body isunchanged, so anything already parsing it is unaffected, and the status stays
200because the service is still serving and should not leave a load balancerover this.
Checks
npm run typecheck,npm run lint,npm run format:check,npm run build,npm run test:run(1192 passing) andnpm run coverage(98.79% statements) pass.The three touched services are at 100% line, branch and function coverage.
The migration was run against a throwaway PostgreSQL 17 rather than assumed: up
creates the table and its
(user_id, occurred_at)index, down drops both, andre-applying works. The counter was exercised end to end against that database,
including the scenario above.