From ef56748c2db5625aff22c84de45b71011429fa36 Mon Sep 17 00:00:00 2001 From: Stavros Date: Tue, 16 Jun 2026 17:35:28 +0300 Subject: [PATCH] fix: use better limits in lockdown to limit dos attack window --- internal/model/config.go | 19 +++++----- internal/service/auth_service.go | 63 ++++++++++++++++++++++++++++---- internal/service/ldap_service.go | 20 ++++++++++ 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/internal/model/config.go b/internal/model/config.go index 975d7e94..85ab57e3 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -120,6 +120,7 @@ type AuthConfig struct { SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` + LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"` } @@ -178,16 +179,16 @@ type UIConfig struct { } type LDAPConfig struct { - Address string `description:"LDAP server address." yaml:"address"` - BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` - BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` + Address string `description:"LDAP server address." yaml:"address"` + BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` + BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"` - BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` - Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` - SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` - AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` - AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` - GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"` + BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` + Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` + SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` + AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` + AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` + GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"` } type LogConfig struct { diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 4e6da9b4..1bffd8d0 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -2,8 +2,10 @@ package service import ( "context" + "crypto/rand" "errors" "fmt" + "math/big" "net/http" "strings" "sync" @@ -25,7 +27,6 @@ import ( // but for now these are just safety limits to prevent unbounded memory usage const MaxOAuthPendingSessions = 256 const OAuthCleanupCount = 16 -const MaxLoginAttemptRecords = 256 var ( ErrUserNotFound = errors.New("user not found") @@ -81,6 +82,8 @@ type AuthService struct { oauth *CacheStore[OAuthPendingSession] ldap *CacheStore[[]string] } + + maxLoginLimits int } type AuthServiceInput struct { @@ -111,9 +114,18 @@ func NewAuthService(i AuthServiceInput) *AuthService { policyEngine: i.PolicyEngine, } + // get the max login limits based on the number of users and the configured max retries + service.maxLoginLimits = service.calculateLockdownLimit() + + loginCacheSize := 0 + + if !service.config.Auth.LockdownEnabled { + loginCacheSize = service.maxLoginLimits + } + // caches setup oauthCache := NewCacheStore[OAuthPendingSession](256) - loginCache := NewCacheStore[LoginAttempt](1024) + loginCache := NewCacheStore[LoginAttempt](loginCacheSize) ldapCache := NewCacheStore[[]string](1024) service.caches.oauth = oauthCache @@ -259,7 +271,7 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { return } - if auth.caches.login.Size() >= MaxLoginAttemptRecords { + if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits { if locked, _ := auth.IsInLockdown(); locked { return } @@ -627,6 +639,12 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending } func (auth *AuthService) lockdownMode() { + defer func() { + if r := recover(); r != nil { + auth.log.App.Error().Interface("panic", r).Msg("Recovered from panic in lockdownMode") + } + }() + auth.lockdown.mu.Lock() if auth.lockdown.active { @@ -634,16 +652,17 @@ func (auth *AuthService) lockdownMode() { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(auth.ctx) auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode") auth.lockdown.active = true auth.lockdown.ctx = ctx auth.lockdown.cancelFunc = cancel - auth.lockdown.until = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) - timer := time.NewTimer(time.Until(auth.lockdown.until)) + d := time.Duration(auth.config.Auth.LoginTimeout) * time.Second + auth.lockdown.until = time.Now().Add(d) + timer := time.NewTimer(d) auth.lockdown.mu.Unlock() @@ -655,14 +674,13 @@ func (auth *AuthService) lockdownMode() { // Timer expired, end lockdown case <-ctx.Done(): // Context cancelled, end lockdown - case <-auth.ctx.Done(): - // Service is shutting down, end lockdown } auth.lockdown.mu.Lock() auth.log.App.Info().Msg("Exiting lockdown mode") + auth.caches.login.Clear() auth.lockdown.active = false auth.lockdown.until = time.Time{} auth.lockdown.ctx = nil @@ -685,3 +703,32 @@ func (auth *AuthService) IsInLockdown() (bool, int) { func (auth *AuthService) ClearLoginAttempts() { auth.caches.login.Clear() } + +func (auth *AuthService) calculateLockdownLimit() int { + userCount := len(auth.runtime.LocalUsers) + + if auth.ldap != nil { + ldapUsers, err := auth.ldap.GetUserCount() + if err != nil { + auth.log.App.Warn().Err(err).Msg("Failed to get LDAP user count") + } else { + userCount += ldapUsers + } + } + + limit := userCount * auth.config.Auth.LoginMaxRetries + + jitter, err := rand.Int(rand.Reader, big.NewInt(64)) + + if err != nil { + auth.log.App.Warn().Err(err).Msg("Failed to generate jitter for lockdown limit") + } else { + limit += int(jitter.Int64()) + } + + if limit < 256 { + limit = 256 + } + + return limit +} diff --git a/internal/service/ldap_service.go b/internal/service/ldap_service.go index 66bb57b4..197e0ed6 100644 --- a/internal/service/ldap_service.go +++ b/internal/service/ldap_service.go @@ -169,6 +169,26 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, return entry.DN, entry.GetAttributeValue("mail"), nil } +func (ldap *LdapService) GetUserCount() (int, error) { + searchRequest := ldapgo.NewSearchRequest( + ldap.config.LDAP.BaseDN, + ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, + "(objectClass=person)", + []string{"dn"}, + nil, + ) + + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + + searchResult, err := ldap.conn.Search(searchRequest) + if err != nil { + return 0, err + } + + return len(searchResult.Entries), nil +} + func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) { escapedUserDN := ldapgo.EscapeFilter(userDN)