Concurrent failed authentication attempts can overwrite one another in the shared security service, preventing an IP ban even after the configured failure threshold has been exceeded. Both Admin and Gateway inherit this implementation.
Evidence and deterministic reproduction
At source commit ee5ea8a022da6a8c3f4f8b79bc8bf67ba499d6ef, SecurityServiceBase.RecordFailedAuthAsync performs an ordinary cache read, increments FailedAuthData.Attempts locally, and writes the serialized object back. The threshold comparison uses this locally computed value. There is no atomic increment or distributed synchronization.
A .NET 10 probe referenced the actual compiled ConduitLLM.Security assembly, derived a minimal service from SecurityServiceBase, and used two instances with separate memory caches and one instrumented IDistributedCache. Failed-auth protection and distributed tracking were enabled, with MaxAttempts=3.
The shared cache captures the first ten absent reads of failed_login:{ip} before releasing them, producing a valid deterministic cross-instance interleaving. All ten requests then execute the real base-class increment/write path. No Redis-specific behavior is required to establish the cache read/write race; this reproduction did not test Redis itself.
Observed:
Sequential: failedRequests=3, banned=True
Concurrent: failedRequests=10, services=2, threshold=3, storedAttempts=1, banned=False
Shared cache: attemptReads=10, attemptWrites=10, banWrites=0
Thus ten simultaneous failed attempts are recorded as one, and the configured threshold does not ban the IP. The sequential control confirms ordinary threshold enforcement still works.
Expected change
Use an atomic shared failed-auth counter and preserve the intended failure-window/ban semantics, including metadata and successful-auth clearing. Keep a synchronized memory implementation for deployments without distributed tracking. Add two-instance concurrency coverage that confirms crossing the threshold triggers a ban and does not lose increments.
Found while fixing #1591. Closed #1211 addressed atomic IP/discovery/model-capability rate counters, but did not cover failed-auth tracking. Searches across open and closed issues for failed-auth races/concurrency found no matching defect. The shared memory path also uses read/increment/write, but was not separately exercised by this reproduction.
Concurrent failed authentication attempts can overwrite one another in the shared security service, preventing an IP ban even after the configured failure threshold has been exceeded. Both Admin and Gateway inherit this implementation.
Evidence and deterministic reproduction
At source commit
ee5ea8a022da6a8c3f4f8b79bc8bf67ba499d6ef,SecurityServiceBase.RecordFailedAuthAsyncperforms an ordinary cache read, incrementsFailedAuthData.Attemptslocally, and writes the serialized object back. The threshold comparison uses this locally computed value. There is no atomic increment or distributed synchronization.A .NET 10 probe referenced the actual compiled
ConduitLLM.Securityassembly, derived a minimal service fromSecurityServiceBase, and used two instances with separate memory caches and one instrumentedIDistributedCache. Failed-auth protection and distributed tracking were enabled, withMaxAttempts=3.The shared cache captures the first ten absent reads of
failed_login:{ip}before releasing them, producing a valid deterministic cross-instance interleaving. All ten requests then execute the real base-class increment/write path. No Redis-specific behavior is required to establish the cache read/write race; this reproduction did not test Redis itself.Observed:
Thus ten simultaneous failed attempts are recorded as one, and the configured threshold does not ban the IP. The sequential control confirms ordinary threshold enforcement still works.
Expected change
Use an atomic shared failed-auth counter and preserve the intended failure-window/ban semantics, including metadata and successful-auth clearing. Keep a synchronized memory implementation for deployments without distributed tracking. Add two-instance concurrency coverage that confirms crossing the threshold triggers a ban and does not lose increments.
Found while fixing #1591. Closed #1211 addressed atomic IP/discovery/model-capability rate counters, but did not cover failed-auth tracking. Searches across open and closed issues for failed-auth races/concurrency found no matching defect. The shared memory path also uses read/increment/write, but was not separately exercised by this reproduction.