-
Notifications
You must be signed in to change notification settings - Fork 1
fix(sentinel): close audit drop and login attempt gaps #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,10 @@ const ( | |
| apiLoginLockoutBase = 15 * time.Second | ||
| apiLoginLockoutMax = 5 * time.Minute | ||
| ) | ||
| const ( | ||
| apiLoginAttemptIdleTTL = 30 * time.Minute | ||
| apiLoginAttemptMaxEntries = 4096 | ||
| ) | ||
| const ( | ||
| platformSignupRequestMaxBytes = 4 * 1024 | ||
| platformPasswordLoginRequestMaxBytes = 4 * 1024 | ||
|
|
@@ -43,6 +47,7 @@ type passwordUserEnsurer interface { | |
| type apiLoginAttempt struct { | ||
| failures int | ||
| lockedUntil time.Time | ||
| lastSeen time.Time | ||
| } | ||
|
|
||
| type apiLoginAttemptTracker struct { | ||
|
|
@@ -61,8 +66,14 @@ func newAPILoginAttemptTracker(nowFn func() time.Time) *apiLoginAttemptTracker { | |
| func (t *apiLoginAttemptTracker) allow(key string) bool { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
| state := t.entries[key] | ||
| now := t.nowFunc() | ||
| t.pruneLocked(now) | ||
| state, ok := t.entries[key] | ||
| if !ok { | ||
| return true | ||
| } | ||
| state.lastSeen = now | ||
| t.entries[key] = state | ||
| if state.lockedUntil.IsZero() || !state.lockedUntil.After(now) { | ||
| return true | ||
| } | ||
|
|
@@ -73,22 +84,55 @@ func (t *apiLoginAttemptTracker) recordFailure(key string) int { | |
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
| now := t.nowFunc() | ||
| t.pruneLocked(now) | ||
| state := t.entries[key] | ||
| state.failures++ | ||
| state.lockedUntil = now.Add(lockoutDurationForFailures(state.failures)) | ||
| state.lastSeen = now | ||
| t.entries[key] = state | ||
| t.enforceMaxLocked() | ||
| return state.failures | ||
| } | ||
|
|
||
| func (t *apiLoginAttemptTracker) recordSuccess(key string) int { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
| now := t.nowFunc() | ||
| t.pruneLocked(now) | ||
| state := t.entries[key] | ||
| failures := state.failures | ||
| delete(t.entries, key) | ||
| return failures | ||
| } | ||
|
|
||
| func (t *apiLoginAttemptTracker) pruneLocked(now time.Time) { | ||
| if apiLoginAttemptIdleTTL <= 0 { | ||
| return | ||
| } | ||
| for key, state := range t.entries { | ||
| if state.lastSeen.IsZero() || (now.Sub(state.lastSeen) > apiLoginAttemptIdleTTL && !state.lockedUntil.After(now)) { | ||
| delete(t.entries, key) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (t *apiLoginAttemptTracker) enforceMaxLocked() { | ||
| for len(t.entries) > apiLoginAttemptMaxEntries { | ||
| var oldestKey string | ||
| var oldestSeen time.Time | ||
| for key, state := range t.entries { | ||
| if oldestKey == "" || state.lastSeen.Before(oldestSeen) { | ||
| oldestKey = key | ||
| oldestSeen = state.lastSeen | ||
| } | ||
|
Comment on lines
+123
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The API attempt cap also evicts solely by oldest Useful? React with 👍 / 👎. |
||
| } | ||
| if oldestKey == "" { | ||
| return | ||
| } | ||
| delete(t.entries, oldestKey) | ||
| } | ||
| } | ||
|
Comment on lines
+119
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| func lockoutDurationForFailures(failures int) time.Duration { | ||
| if failures <= 2 { | ||
| return 0 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,8 @@ const ( | |
| defaultLoginFailureWindow = 15 * time.Minute | ||
| defaultLoginFailureThreshold = 5 | ||
| defaultLoginLockoutDuration = 5 * time.Minute | ||
| loginAttemptIdleTTL = 30 * time.Minute | ||
| loginAttemptMaxClients = 4096 | ||
| loginFailureLogEvery = 3 | ||
| loginRequestMaxBytes = 8 * 1024 | ||
| ) | ||
|
|
@@ -84,6 +86,7 @@ type loginAttemptTracker struct { | |
| type loginClientState struct { | ||
| tokens int | ||
| lastRefill time.Time | ||
| lastSeen time.Time | ||
| failures int | ||
| failuresExpire time.Time | ||
| lockedUntil time.Time | ||
|
|
@@ -721,8 +724,10 @@ func (t *loginAttemptTracker) allow(clientID string) bool { | |
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
|
|
||
| state := t.stateForLocked(clientID) | ||
| now := t.now() | ||
| t.pruneLocked(now) | ||
| state := t.stateForLocked(clientID, now) | ||
| state.lastSeen = now | ||
| refillLoginTokens(state, now) | ||
| if now.Before(state.lockedUntil) { | ||
| return false | ||
|
|
@@ -731,15 +736,18 @@ func (t *loginAttemptTracker) allow(clientID string) bool { | |
| return false | ||
| } | ||
| state.tokens-- | ||
| t.enforceMaxLocked() | ||
| return true | ||
| } | ||
|
|
||
| func (t *loginAttemptTracker) recordFailure(clientID string) int { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
|
|
||
| state := t.stateForLocked(clientID) | ||
| now := t.now() | ||
| t.pruneLocked(now) | ||
| state := t.stateForLocked(clientID, now) | ||
| state.lastSeen = now | ||
| if now.After(state.failuresExpire) { | ||
| state.failures = 0 | ||
| } | ||
|
|
@@ -748,31 +756,65 @@ func (t *loginAttemptTracker) recordFailure(clientID string) int { | |
| if state.failures >= loginFailureThreshold { | ||
| state.lockedUntil = now.Add(loginLockoutDuration) | ||
| } | ||
| t.enforceMaxLocked() | ||
| return state.failures | ||
| } | ||
|
|
||
| func (t *loginAttemptTracker) recordSuccess(clientID string) int { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
|
|
||
| state := t.stateForLocked(clientID) | ||
| now := t.now() | ||
| t.pruneLocked(now) | ||
| state := t.clients[clientID] | ||
| if state == nil { | ||
| return 0 | ||
| } | ||
| state.lastSeen = now | ||
| prior := state.failures | ||
| state.failures = 0 | ||
| state.failuresExpire = time.Time{} | ||
| state.lockedUntil = time.Time{} | ||
| return prior | ||
| } | ||
|
|
||
| func (t *loginAttemptTracker) stateForLocked(clientID string) *loginClientState { | ||
| func (t *loginAttemptTracker) stateForLocked(clientID string, now time.Time) *loginClientState { | ||
| state := t.clients[clientID] | ||
| if state == nil { | ||
| now := t.now() | ||
| state = &loginClientState{tokens: loginRateLimitCapacity, lastRefill: now} | ||
| state = &loginClientState{tokens: loginRateLimitCapacity, lastRefill: now, lastSeen: now} | ||
| t.clients[clientID] = state | ||
| } | ||
| return state | ||
| } | ||
|
|
||
| func (t *loginAttemptTracker) pruneLocked(now time.Time) { | ||
| if loginAttemptIdleTTL <= 0 { | ||
| return | ||
| } | ||
| for clientID, state := range t.clients { | ||
| if state.lastSeen.IsZero() || (now.Sub(state.lastSeen) > loginAttemptIdleTTL && !now.Before(state.lockedUntil)) { | ||
| delete(t.clients, clientID) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+790
to
+799
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the API service, |
||
|
|
||
| func (t *loginAttemptTracker) enforceMaxLocked() { | ||
| for len(t.clients) > loginAttemptMaxClients { | ||
| var oldestClientID string | ||
| var oldestSeen time.Time | ||
| for clientID, state := range t.clients { | ||
| if oldestClientID == "" || state.lastSeen.Before(oldestSeen) { | ||
| oldestClientID = clientID | ||
| oldestSeen = state.lastSeen | ||
| } | ||
|
Comment on lines
+805
to
+809
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The eviction policy removes whichever client has the oldest Useful? React with 👍 / 👎. |
||
| } | ||
| if oldestClientID == "" { | ||
| return | ||
| } | ||
| delete(t.clients, oldestClientID) | ||
| } | ||
| } | ||
|
|
||
| func refillLoginTokens(state *loginClientState, now time.Time) { | ||
| if state.lastRefill.IsZero() { | ||
| state.lastRefill = now | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
pruneLockedmethod iterates over the entireentriesmap on every call toallow,recordFailure, andrecordSuccess. With a default limit of 4096 entries, this O(N) operation per request introduces significant CPU overhead, especially under high load or during a brute-force attack. This could be exploited as a CPU exhaustion vector.Consider performing pruning periodically (e.g., using a background goroutine) or only when the map size exceeds a certain threshold to decouple this maintenance from the request path.