From 7020c9d5f74f21cbcd625fbf5d281b580064d819 Mon Sep 17 00:00:00 2001 From: Christian Roessner Date: Sun, 29 Sep 2024 15:27:21 +0200 Subject: [PATCH] Add brute force protection feature and update config params Implemented brute force protection with configurable history limits. Renamed `MaxConnections` to `MaxConcurrentRequests` and added `MaxPasswordHistoryEntries` configuration. Integrated corresponding validation and updated relevant documentation. Signed-off-by: Christian Roessner --- server/config/file.go | 20 +++++++++---- server/config/server.go | 39 ++++++++++++------------- server/config/types.go | 2 +- server/core/bruteforce.go | 60 ++++++++++++++++++++++++++++++--------- server/core/http.go | 2 +- server/global/const.go | 10 +++++-- 6 files changed, 92 insertions(+), 41 deletions(-) diff --git a/server/config/file.go b/server/config/file.go index 9411d5b6..66ca91ec 100644 --- a/server/config/file.go +++ b/server/config/file.go @@ -1479,14 +1479,23 @@ func (f *File) validateHTTPRequestHeaders() error { return nil } -// validateMaxConnections ensures that the MaxConnections parameter is set to a valid value. +// validateMaxConnections ensures that the MaxConcurrentRequests parameter is set to a valid value. func (f *File) validateMaxConnections() error { - if f.Server.MaxConnections == 0 { - f.Server.MaxConnections = global.MaxConnections + if f.Server.MaxConcurrentRequests == 0 { + f.Server.MaxConcurrentRequests = global.MaxConcurrentRequests } - if f.Server.MaxConnections < 0 { - f.Server.MaxConnections = global.MaxConnections + if f.Server.MaxConcurrentRequests < 0 { + f.Server.MaxConcurrentRequests = global.MaxConcurrentRequests + } + + return nil +} + +// validateMaxPasswordHistoryEntries sets MaxPasswordHistoryEntries to a default value if non-positive and returns an error if any. +func (f *File) validateMaxPasswordHistoryEntries() error { + if f.Server.MaxPasswordHistoryEntries <= 0 { + f.Server.MaxPasswordHistoryEntries = global.MaxPasswordHistoryEntries } return nil @@ -1521,6 +1530,7 @@ func (f *File) validate() (err error) { f.validateMasterUserDelimiter, f.validateHTTPRequestHeaders, f.validateMaxConnections, + f.validateMaxPasswordHistoryEntries, } for _, validator := range validators { diff --git a/server/config/server.go b/server/config/server.go index 2352c029..d136116b 100644 --- a/server/config/server.go +++ b/server/config/server.go @@ -22,25 +22,26 @@ import ( // ServerSection represents the configuration for a server, including network settings, TLS, logging, backends, features, // protocol handling, and integrations with other systems such as Redis and Prometheus. type ServerSection struct { - Address string `mapstructure:"address"` - MaxConnections int32 `mapstructure:"max_connections"` - HTTP3 bool `mapstructure:"http3"` - HAproxyV2 bool `mapstructure:"haproxy_v2"` - TLS TLS `mapstructure:"tls"` - BasicAuth BasicAuth `mapstructure:"basic_auth"` - InstanceName string `mapstructure:"instance_name"` - Log Log `maptostructure:"log"` - Backends []*Backend `mapstructure:"backends"` - Features []*Feature `mapstructure:"features"` - BruteForceProtocols []*Protocol `mapstructure:"brute_force_protocols"` - HydraAdminUrl string `mapstructure:"ory_hydra_admin_url"` - DNS DNS `mapstructure:"dns"` - Insights Insights `mapstructure:"insights"` - Redis Redis `mapstructure:"redis"` - MasterUser MasterUser `mapstructure:"master_user"` - Frontend Frontend `mapstructure:"frontend"` - PrometheusTimer PrometheusTimer `mapstructure:"prometheus_timer"` - DefaultHTTPRequestHeader DefaultHTTPRequestHeader `mapstructure:"default_http_request_header"` + Address string `mapstructure:"address"` + MaxConcurrentRequests int32 `mapstructure:"max_concurrent_requests"` + MaxPasswordHistoryEntries int32 `mapstructure:"max_password_history_entries"` + HTTP3 bool `mapstructure:"http3"` + HAproxyV2 bool `mapstructure:"haproxy_v2"` + TLS TLS `mapstructure:"tls"` + BasicAuth BasicAuth `mapstructure:"basic_auth"` + InstanceName string `mapstructure:"instance_name"` + Log Log `maptostructure:"log"` + Backends []*Backend `mapstructure:"backends"` + Features []*Feature `mapstructure:"features"` + BruteForceProtocols []*Protocol `mapstructure:"brute_force_protocols"` + HydraAdminUrl string `mapstructure:"ory_hydra_admin_url"` + DNS DNS `mapstructure:"dns"` + Insights Insights `mapstructure:"insights"` + Redis Redis `mapstructure:"redis"` + MasterUser MasterUser `mapstructure:"master_user"` + Frontend Frontend `mapstructure:"frontend"` + PrometheusTimer PrometheusTimer `mapstructure:"prometheus_timer"` + DefaultHTTPRequestHeader DefaultHTTPRequestHeader `mapstructure:"default_http_request_header"` } // TLS represents the configuration for enabling TLS and managing certificates. diff --git a/server/config/types.go b/server/config/types.go index c7eca6e5..d38747b7 100644 --- a/server/config/types.go +++ b/server/config/types.go @@ -214,7 +214,7 @@ func (f *Feature) String() string { func (f *Feature) Set(value string) error { switch value { case "": - case global.FeatureTLSEncryption, global.FeatureRBL, global.FeatureRelayDomains, global.FeatureLua, global.FeatureBackendServersMonitoring: + case global.FeatureTLSEncryption, global.FeatureRBL, global.FeatureRelayDomains, global.FeatureLua, global.FeatureBackendServersMonitoring, global.FeatureBruteForce: f.name = value default: return errors.ErrWrongFeature diff --git a/server/core/bruteforce.go b/server/core/bruteforce.go index df581678..9179b75c 100644 --- a/server/core/bruteforce.go +++ b/server/core/bruteforce.go @@ -306,6 +306,29 @@ func (a *AuthState) getBruteForceBucketRedisKey(rule *config.BruteForceRule) (ke return } +// checkTooManyPasswordHashes checks if the number of password hashes for a given Redis key exceeds the configured limit. +func (a *AuthState) checkTooManyPasswordHashes(key string) bool { + if length, err := rediscli.ReadHandle.HLen(context.Background(), key).Result(); err != nil { + if !stderrors.Is(err, redis.Nil) { + level.Error(log.Logger).Log(global.LogKeyGUID, a.GUID, global.LogKeyError, err) + } else { + stats.RedisReadCounter.Inc() + } + + return true + } else { + if length > int64(config.LoadableConfig.Server.MaxPasswordHistoryEntries) { + level.Error(log.Logger).Log(global.LogKeyGUID, a.GUID, global.LogKeyError, fmt.Sprintf("too many entries in Redis hash key %s", key)) + + stats.RedisReadCounter.Inc() + + return true + } + } + + return false +} + // loadBruteForcePasswordHistoryFromRedis loads password history related to brute force attacks from Redis for a given key. // The function will fetch all associated passwords in the form of a hash along with a counter. // The Redis key is created for each unique user presented by the variable `key` which is a GUID, @@ -324,6 +347,10 @@ func (a *AuthState) loadBruteForcePasswordHistoryFromRedis(key string) { util.DebugModule(global.DbgBf, global.LogKeyGUID, a.GUID, "load_key", key) + if a.checkTooManyPasswordHashes(key) { + return + } + if passwordHistory, err := rediscli.ReadHandle.HGetAll(context.Background(), key).Result(); err != nil { if !stderrors.Is(err, redis.Nil) { level.Error(log.Logger).Log(global.LogKeyGUID, a.GUID, global.LogKeyError, err) @@ -363,6 +390,10 @@ func (a *AuthState) loadBruteForcePasswordHistoryFromRedis(key string) { // This overall history is then used to compute the total number of seen passwords. // Each of these phases are independent and are executed if the Redis hash key retrieval and the password history fetch operations are successful. func (a *AuthState) getAllPasswordHistories() { + if !config.LoadableConfig.HasFeature(global.FeatureBruteForce) { + return + } + // Get password history for the current used username if key := a.getBruteForcePasswordHistoryRedisHashKey(true); key != "" { a.loadBruteForcePasswordHistoryFromRedis(key) @@ -401,12 +432,20 @@ func (a *AuthState) getAllPasswordHistories() { // // The function concludes by logging that the process has finished. func (a *AuthState) saveBruteForcePasswordToRedis() { + if !config.LoadableConfig.HasFeature(global.FeatureBruteForce) { + return + } + var keys []string keys = append(keys, a.getBruteForcePasswordHistoryRedisHashKey(true)) keys = append(keys, a.getBruteForcePasswordHistoryRedisHashKey(false)) for index := range keys { + if a.checkTooManyPasswordHashes(keys[index]) { + continue + } + util.DebugModule(global.DbgBf, global.LogKeyGUID, a.GUID, "incr_key", keys[index]) // We can increment a key/value, even it never existed before. @@ -434,20 +473,7 @@ func (a *AuthState) saveBruteForcePasswordToRedis() { } else { stats.RedisWriteCounter.Inc() } - - util.DebugModule( - global.DbgBf, - global.LogKeyGUID, a.GUID, - "key", keys[index], - global.LogKeyMsg, "Set expire", - ) } - - util.DebugModule( - global.DbgBf, - global.LogKeyGUID, a.GUID, - global.LogKeyMsg, "Finished", - ) } // loadBruteForceBucketCounterFromRedis is a method on the AuthState struct that loads the brute force @@ -457,6 +483,10 @@ func (a *AuthState) saveBruteForcePasswordToRedis() { // If the BruteForceCounter is not initialized, it creates a new map. // Finally, it updates the BruteForceCounter map with the counter value retrieved from Redis using the rule name as the key. func (a *AuthState) loadBruteForceBucketCounterFromRedis(rule *config.BruteForceRule) { + if !config.LoadableConfig.HasFeature(global.FeatureBruteForce) { + return + } + cache := new(backend.BruteForceBucketCache) if key := a.getBruteForceBucketRedisKey(rule); key != "" { @@ -608,6 +638,10 @@ func (a *AuthState) checkBruteForce() (blockClientIP bool) { network *net.IPNet ) + if !config.LoadableConfig.HasFeature(global.FeatureBruteForce) { + return + } + stopTimer := stats.PrometheusTimer(global.PromBruteForce, "brute_force_check_request_total") defer stopTimer() diff --git a/server/core/http.go b/server/core/http.go index e77e6b32..8705ea9b 100644 --- a/server/core/http.go +++ b/server/core/http.go @@ -1037,7 +1037,7 @@ func HTTPApp(ctx context.Context) { pprof.Register(router) } - limitCounter := NewLimitCounter(config.LoadableConfig.Server.MaxConnections) + limitCounter := NewLimitCounter(config.LoadableConfig.Server.MaxConcurrentRequests) router.Use(limitCounter.Middleware()) diff --git a/server/global/const.go b/server/global/const.go index 2379d9e3..ae72122c 100644 --- a/server/global/const.go +++ b/server/global/const.go @@ -289,8 +289,11 @@ const ( // MaxActionWorkers is the maximum number of action workers MaxActionWorkers = 10 - // MaxConnections represents the maximum number of simultaneous connections allowed. - MaxConnections = 3000 + // MaxConcurrentRequests represents the maximum number of simultaneous connections allowed. + MaxConcurrentRequests = 3000 + + // MaxPasswordHistoryEntries defines the maximum number of previous passwords to store for history and validation purposes. + MaxPasswordHistoryEntries = 100 ) // Log level. @@ -362,6 +365,9 @@ const ( // FeatureBackendServersMonitoring enables a custom backend list with fail-state monitoring FeatureBackendServersMonitoring = "backend_server_monitoring" + + // FeatureBruteForce enables the brute force protection system + FeatureBruteForce = "brute_force" ) // Statistics label for the loin counter.