Skip to content

fix(security): stop an audit write failure disabling account lockout - #227

Merged
Bccorb merged 1 commit into
mainfrom
fix/audit-failure-does-not-disable-lockout
Aug 31, 2026
Merged

fix(security): stop an audit write failure disabling account lockout#227
Bccorb merged 1 commit into
mainfrom
fix/audit-failure-does-not-disable-lockout

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 every
error. 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_events out from under a
running instance, then re-run against this branch:

audit writes failing: true
failures counted anyway: 10
ACCOUNT LOCKED:        true

Before, that same scenario counted zero and left the account open.

What changed

Failed attempts have their own table. auth_failures is written separately
from 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, so
none of the 137 call sites change.

An unknown count means locked. getUserLockoutStatus refuses rather than
guessing when it cannot read the counter, returning the same 423 a locked
account gets.

Failures are reported where monitoring already looks. GET /health/status
answers 200 { "message": "System up, audit degraded", "degraded": { "audit": … } } for five minutes after a failed audit write. That is the defined action AU-5
asks for; a log line nobody reads is not.

A correction to the advisory

The advisory's fourth remediation item says the || 0 coalescing "also yields
zero" when the count query throws, which would read as not locked. That part does
not hold: Number(await count()) || 0 propagates a rejection rather than
coalescing it, so a throwing query surfaced as a 500, not a bypass. I checked
before 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 423 is the honest answer there and a 500 was not, but
it 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.md
rather than left implicit, and a configurable fail-closed authentication mode is
noted there as not available today.

auth_failures rows are not pruned, which matches auth_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/status gains an optional degraded object. The healthy body is
unchanged, so anything already parsing it is unaffected, and the status stays
200 because the service is still serving and should not leave a load balancer
over this.

Checks

npm run typecheck, npm run lint, npm run format:check, npm run build,
npm run test:run (1192 passing) and npm 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, and
re-applying works. The counter was exercised end to end against that database,
including the scenario above.

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
@Bccorb
Bccorb merged commit 3d21962 into main Aug 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit write failures are logged but not acted on (AU-5)

1 participant