Skip to content

Commit 654b5cc

Browse files
authored
fix: use better limits in lockdown to limit dos attack window (#943)
1 parent f7d7f1c commit 654b5cc

3 files changed

Lines changed: 80 additions & 17 deletions

File tree

internal/model/config.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func NewDefaultConfiguration() *Config {
2828
ACLs: ACLsConfig{
2929
Policy: "allow",
3030
},
31+
LockdownEnabled: true,
3132
},
3233
UI: UIConfig{
3334
Title: "Tinyauth",
@@ -120,6 +121,7 @@ type AuthConfig struct {
120121
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
121122
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
122123
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
124+
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled"`
123125
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
124126
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
125127
}
@@ -178,16 +180,16 @@ type UIConfig struct {
178180
}
179181

180182
type LDAPConfig struct {
181-
Address string `description:"LDAP server address." yaml:"address"`
182-
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
183-
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
183+
Address string `description:"LDAP server address." yaml:"address"`
184+
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
185+
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
184186
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"`
185-
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
186-
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
187-
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
188-
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
189-
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
190-
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"`
187+
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
188+
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
189+
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
190+
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
191+
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
192+
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"`
191193
}
192194

193195
type LogConfig struct {

internal/service/auth_service.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package service
22

33
import (
44
"context"
5+
"crypto/rand"
56
"errors"
67
"fmt"
8+
"math/big"
79
"net/http"
810
"strings"
911
"sync"
@@ -25,7 +27,6 @@ import (
2527
// but for now these are just safety limits to prevent unbounded memory usage
2628
const MaxOAuthPendingSessions = 256
2729
const OAuthCleanupCount = 16
28-
const MaxLoginAttemptRecords = 256
2930

3031
var (
3132
ErrUserNotFound = errors.New("user not found")
@@ -81,6 +82,8 @@ type AuthService struct {
8182
oauth *CacheStore[OAuthPendingSession]
8283
ldap *CacheStore[[]string]
8384
}
85+
86+
maxLoginLimits int
8487
}
8588

8689
type AuthServiceInput struct {
@@ -111,9 +114,18 @@ func NewAuthService(i AuthServiceInput) *AuthService {
111114
policyEngine: i.PolicyEngine,
112115
}
113116

117+
// get the max login limits based on the number of users and the configured max retries
118+
service.maxLoginLimits = service.calculateLockdownLimit()
119+
120+
loginCacheSize := 0
121+
122+
if !service.config.Auth.LockdownEnabled {
123+
loginCacheSize = service.maxLoginLimits
124+
}
125+
114126
// caches setup
115127
oauthCache := NewCacheStore[OAuthPendingSession](256)
116-
loginCache := NewCacheStore[LoginAttempt](1024)
128+
loginCache := NewCacheStore[LoginAttempt](loginCacheSize)
117129
ldapCache := NewCacheStore[[]string](1024)
118130

119131
service.caches.oauth = oauthCache
@@ -259,7 +271,7 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
259271
return
260272
}
261273

262-
if auth.caches.login.Size() >= MaxLoginAttemptRecords {
274+
if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits {
263275
if locked, _ := auth.IsInLockdown(); locked {
264276
return
265277
}
@@ -634,16 +646,17 @@ func (auth *AuthService) lockdownMode() {
634646
return
635647
}
636648

637-
ctx, cancel := context.WithCancel(context.Background())
649+
ctx, cancel := context.WithCancel(auth.ctx)
638650

639651
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
640652

641653
auth.lockdown.active = true
642654
auth.lockdown.ctx = ctx
643655
auth.lockdown.cancelFunc = cancel
644-
auth.lockdown.until = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
645656

646-
timer := time.NewTimer(time.Until(auth.lockdown.until))
657+
d := time.Duration(auth.config.Auth.LoginTimeout) * time.Second
658+
auth.lockdown.until = time.Now().Add(d)
659+
timer := time.NewTimer(d)
647660

648661
auth.lockdown.mu.Unlock()
649662

@@ -655,14 +668,13 @@ func (auth *AuthService) lockdownMode() {
655668
// Timer expired, end lockdown
656669
case <-ctx.Done():
657670
// Context cancelled, end lockdown
658-
case <-auth.ctx.Done():
659-
// Service is shutting down, end lockdown
660671
}
661672

662673
auth.lockdown.mu.Lock()
663674

664675
auth.log.App.Info().Msg("Exiting lockdown mode")
665676

677+
auth.caches.login.Clear()
666678
auth.lockdown.active = false
667679
auth.lockdown.until = time.Time{}
668680
auth.lockdown.ctx = nil
@@ -685,3 +697,32 @@ func (auth *AuthService) IsInLockdown() (bool, int) {
685697
func (auth *AuthService) ClearLoginAttempts() {
686698
auth.caches.login.Clear()
687699
}
700+
701+
func (auth *AuthService) calculateLockdownLimit() int {
702+
userCount := len(auth.runtime.LocalUsers)
703+
704+
if auth.ldap != nil {
705+
ldapUsers, err := auth.ldap.GetUserCount()
706+
if err != nil {
707+
auth.log.App.Warn().Err(err).Msg("Failed to get LDAP user count")
708+
} else {
709+
userCount += ldapUsers
710+
}
711+
}
712+
713+
limit := userCount * auth.config.Auth.LoginMaxRetries
714+
715+
jitter, err := rand.Int(rand.Reader, big.NewInt(64))
716+
717+
if err != nil {
718+
auth.log.App.Warn().Err(err).Msg("Failed to generate jitter for lockdown limit")
719+
} else {
720+
limit += int(jitter.Int64())
721+
}
722+
723+
if limit < 256 {
724+
limit = 256
725+
}
726+
727+
return limit
728+
}

internal/service/ldap_service.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,26 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
169169
return entry.DN, entry.GetAttributeValue("mail"), nil
170170
}
171171

172+
func (ldap *LdapService) GetUserCount() (int, error) {
173+
searchRequest := ldapgo.NewSearchRequest(
174+
ldap.config.LDAP.BaseDN,
175+
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
176+
"(objectClass=person)",
177+
[]string{"dn"},
178+
nil,
179+
)
180+
181+
ldap.mutex.Lock()
182+
defer ldap.mutex.Unlock()
183+
184+
searchResult, err := ldap.conn.Search(searchRequest)
185+
if err != nil {
186+
return 0, err
187+
}
188+
189+
return len(searchResult.Entries), nil
190+
}
191+
172192
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
173193
escapedUserDN := ldapgo.EscapeFilter(userDN)
174194

0 commit comments

Comments
 (0)