From 26cc043159bf4fc36c01b8a7f04af0496b3c95e4 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 14:29:23 +0100 Subject: [PATCH 1/3] feat(security): Add configurable detection thresholds - Add detection threshold fields to SecurityConfig (detection_window, not_found_threshold, auth_failure_threshold, unique_paths_threshold, repeated_hits_threshold) - Update API handler to accept and persist new threshold settings - Apply thresholds to detector on startup and settings update - Add SetDetectorThresholds method to security manager Signed-off-by: nfebe --- internal/api/security_handlers.go | 62 +++++++++++++++++++++++++++---- internal/api/server.go | 9 +++++ internal/security/manager.go | 5 +++ pkg/config/config.go | 23 ++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index f16b453..69e45e4 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -611,6 +611,12 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { AutoBlockEnabled *bool `json:"auto_block_enabled"` AutoBlockThreshold int `json:"auto_block_threshold"` AutoBlockDuration string `json:"auto_block_duration"` + // Detection thresholds + DetectionWindow string `json:"detection_window"` + NotFoundThreshold int `json:"not_found_threshold"` + AuthFailureThreshold int `json:"auth_failure_threshold"` + UniquePathsThreshold int `json:"unique_paths_threshold"` + RepeatedHitsThreshold int `json:"repeated_hits_threshold"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -670,6 +676,29 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { updatedFields = append(updatedFields, "auto_block_duration") } } + // Detection thresholds + if req.DetectionWindow != "" { + if d, err := time.ParseDuration(req.DetectionWindow); err == nil { + s.config.Security.DetectionWindow = d + updatedFields = append(updatedFields, "detection_window") + } + } + if req.NotFoundThreshold > 0 { + s.config.Security.NotFoundThreshold = req.NotFoundThreshold + updatedFields = append(updatedFields, "not_found_threshold") + } + if req.AuthFailureThreshold > 0 { + s.config.Security.AuthFailureThreshold = req.AuthFailureThreshold + updatedFields = append(updatedFields, "auth_failure_threshold") + } + if req.UniquePathsThreshold > 0 { + s.config.Security.UniquePathsThreshold = req.UniquePathsThreshold + updatedFields = append(updatedFields, "unique_paths_threshold") + } + if req.RepeatedHitsThreshold > 0 { + s.config.Security.RepeatedHitsThreshold = req.RepeatedHitsThreshold + updatedFields = append(updatedFields, "repeated_hits_threshold") + } result["updated_fields"] = updatedFields @@ -713,16 +742,33 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { // Update dependent managers s.infraManager.UpdateConfig(s.config) + // Update detector thresholds if security manager is available + if s.securityManager != nil { + s.securityManager.SetDetectorThresholds( + s.config.Security.RateThreshold, + s.config.Security.NotFoundThreshold, + s.config.Security.AuthFailureThreshold, + s.config.Security.UniquePathsThreshold, + s.config.Security.RepeatedHitsThreshold, + s.config.Security.DetectionWindow, + ) + } + // Return current security settings result["security"] = gin.H{ - "enabled": s.config.Security.Enabled, - "realtime_capture": s.config.Security.RealtimeCapture, - "scan_interval": s.config.Security.ScanInterval.String(), - "retention_days": s.config.Security.RetentionDays, - "rate_threshold": s.config.Security.RateThreshold, - "auto_block_enabled": s.config.Security.AutoBlockEnabled, - "auto_block_threshold": s.config.Security.AutoBlockThreshold, - "auto_block_duration": s.config.Security.AutoBlockDuration.String(), + "enabled": s.config.Security.Enabled, + "realtime_capture": s.config.Security.RealtimeCapture, + "scan_interval": s.config.Security.ScanInterval.String(), + "retention_days": s.config.Security.RetentionDays, + "rate_threshold": s.config.Security.RateThreshold, + "auto_block_enabled": s.config.Security.AutoBlockEnabled, + "auto_block_threshold": s.config.Security.AutoBlockThreshold, + "auto_block_duration": s.config.Security.AutoBlockDuration.String(), + "detection_window": s.config.Security.DetectionWindow.String(), + "not_found_threshold": s.config.Security.NotFoundThreshold, + "auth_failure_threshold": s.config.Security.AuthFailureThreshold, + "unique_paths_threshold": s.config.Security.UniquePathsThreshold, + "repeated_hits_threshold": s.config.Security.RepeatedHitsThreshold, } c.JSON(http.StatusOK, result) diff --git a/internal/api/server.go b/internal/api/server.go index 0233f2e..ae47079 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -91,6 +91,15 @@ func New(cfg *config.Config, configPath string) *Server { if err != nil { log.Printf("Warning: Failed to initialize security manager: %v", err) } else { + // Apply detection thresholds from config + securityManager.SetDetectorThresholds( + cfg.Security.RateThreshold, + cfg.Security.NotFoundThreshold, + cfg.Security.AuthFailureThreshold, + cfg.Security.UniquePathsThreshold, + cfg.Security.RepeatedHitsThreshold, + cfg.Security.DetectionWindow, + ) nginxConfigPath := cfg.Nginx.ConfigPath if nginxConfigPath == "" { nginxConfigPath = filepath.Join(cfg.DeploymentsPath, "nginx", "conf.d") diff --git a/internal/security/manager.go b/internal/security/manager.go index 05a3105..cae4cab 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -37,6 +37,11 @@ func (m *Manager) Close() error { return m.db.Close() } +// SetDetectorThresholds updates the detector's behavior thresholds +func (m *Manager) SetDetectorThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold int, windowDuration time.Duration) { + m.detector.SetThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold, windowDuration) +} + // IngestResult contains the result of event ingestion type IngestResult struct { Event *SecurityEvent diff --git a/pkg/config/config.go b/pkg/config/config.go index 12f8688..9c2d573 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -119,6 +119,13 @@ type SecurityConfig struct { AutoBlockEnabled bool `yaml:"auto_block_enabled" json:"auto_block_enabled"` AutoBlockThreshold int `yaml:"auto_block_threshold" json:"auto_block_threshold"` AutoBlockDuration time.Duration `yaml:"auto_block_duration" json:"auto_block_duration"` + + // Detection thresholds for autoblock + DetectionWindow time.Duration `yaml:"detection_window" json:"detection_window"` + NotFoundThreshold int `yaml:"not_found_threshold" json:"not_found_threshold"` + AuthFailureThreshold int `yaml:"auth_failure_threshold" json:"auth_failure_threshold"` + UniquePathsThreshold int `yaml:"unique_paths_threshold" json:"unique_paths_threshold"` + RepeatedHitsThreshold int `yaml:"repeated_hits_threshold" json:"repeated_hits_threshold"` } func FindConfigPath(providedPath string) string { @@ -245,6 +252,22 @@ func setDefaults(cfg *Config) { if cfg.Security.AutoBlockDuration == 0 { cfg.Security.AutoBlockDuration = 24 * time.Hour } + // Detection threshold defaults + if cfg.Security.DetectionWindow == 0 { + cfg.Security.DetectionWindow = 2 * time.Minute + } + if cfg.Security.NotFoundThreshold == 0 { + cfg.Security.NotFoundThreshold = 10 + } + if cfg.Security.AuthFailureThreshold == 0 { + cfg.Security.AuthFailureThreshold = 5 + } + if cfg.Security.UniquePathsThreshold == 0 { + cfg.Security.UniquePathsThreshold = 20 + } + if cfg.Security.RepeatedHitsThreshold == 0 { + cfg.Security.RepeatedHitsThreshold = 30 + } } func Save(cfg *Config, path string) error { From 6648a99965842aecc5e538a24f4502c4e9ae5dce Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 14:36:51 +0100 Subject: [PATCH 2/3] fix(security): Make blocked-ips endpoint public for nginx access Move GET /security/blocked-ips from protected routes to public routes so nginx Lua can fetch the blocked IPs list without authentication. --- internal/api/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/server.go b/internal/api/server.go index ae47079..1abcdda 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -276,7 +276,6 @@ func (s *Server) setupRoutes() { protected.GET("/security/events", s.listSecurityEvents) protected.GET("/security/events/:id", s.getSecurityEvent) protected.POST("/security/cleanup", s.cleanupSecurityEvents) - protected.GET("/security/blocked-ips", s.listBlockedIPs) protected.POST("/security/blocked-ips", s.blockIP) protected.DELETE("/security/blocked-ips/:ip", s.unblockIP) protected.GET("/security/ips/:ip/events", s.getEventsByIP) @@ -302,6 +301,7 @@ func (s *Server) setupRoutes() { // Ingest endpoints (no auth - called by nginx Lua) api.POST("/security/events/ingest", s.ingestSecurityEvent) api.POST("/traffic/ingest", s.ingestTrafficLog) + api.GET("/security/blocked-ips", s.listBlockedIPs) } } From fd179b69a6ce95d7a7fd7d7509bbc7b26748305c Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 15:05:12 +0100 Subject: [PATCH 3/3] feat(security): Add token-authenticated internal endpoint for nginx Add secure internal endpoint for nginx to fetch blocked IPs without requiring user authentication credentials. - Add InternalAPIToken to SecurityConfig, auto-generated if empty - Create /_internal/blocked-ips endpoint with X-Internal-Token validation - Update Lua templates to include token in requests - Inject token into security.lua during template generation Signed-off-by: nfebe --- internal/api/security_handlers.go | 13 +++++++++++++ internal/api/server.go | 5 ++++- internal/infra/manager.go | 4 ++-- pkg/config/config.go | 11 +++++++++++ templates/infra/nginx/lua/security.lua | 4 +++- templates/templates.go | 12 +++++++----- test/e2e/nginx/lua/security.lua | 6 +++++- 7 files changed, 45 insertions(+), 10 deletions(-) diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index 69e45e4..8d8de60 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -194,6 +194,19 @@ func (s *Server) listBlockedIPs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"blocked_ips": ips}) } +// listBlockedIPsInternal returns blocked IPs for internal nginx communication +func (s *Server) listBlockedIPsInternal(c *gin.Context) { + token := c.GetHeader("X-Internal-Token") + expectedToken := s.config.Security.InternalAPIToken + + if token == "" || token != expectedToken { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid internal token"}) + return + } + + s.listBlockedIPs(c) +} + // blockIP blocks an IP address func (s *Server) blockIP(c *gin.Context) { if s.securityManager == nil { diff --git a/internal/api/server.go b/internal/api/server.go index 1abcdda..8fdc324 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -276,6 +276,7 @@ func (s *Server) setupRoutes() { protected.GET("/security/events", s.listSecurityEvents) protected.GET("/security/events/:id", s.getSecurityEvent) protected.POST("/security/cleanup", s.cleanupSecurityEvents) + protected.GET("/security/blocked-ips", s.listBlockedIPs) protected.POST("/security/blocked-ips", s.blockIP) protected.DELETE("/security/blocked-ips/:ip", s.unblockIP) protected.GET("/security/ips/:ip/events", s.getEventsByIP) @@ -301,7 +302,9 @@ func (s *Server) setupRoutes() { // Ingest endpoints (no auth - called by nginx Lua) api.POST("/security/events/ingest", s.ingestSecurityEvent) api.POST("/traffic/ingest", s.ingestTrafficLog) - api.GET("/security/blocked-ips", s.listBlockedIPs) + + // Internal nginx endpoint - token-authenticated for blocked IPs + api.GET("/_internal/blocked-ips", s.listBlockedIPsInternal) } } diff --git a/internal/infra/manager.go b/internal/infra/manager.go index 72f65ab..f957c54 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -377,7 +377,7 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in result["agent_ip"] = agentIP result["agent_port"] = agentPort - securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort) + securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort, m.config.Security.InternalAPIToken) if err != nil { errors = append(errors, fmt.Sprintf("failed to get security.lua template: %v", err)) } else { @@ -1228,7 +1228,7 @@ func (m *Manager) RefreshSecurityScripts() (*RefreshSecurityScriptsResult, error } // Generate and write security.lua with injected IP - securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort) + securityLua, err := templates.GetNginxSecurityLuaWithConfig(agentIP, agentPort, m.config.Security.InternalAPIToken) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("failed to generate security.lua: %v", err)) result.Success = false diff --git a/pkg/config/config.go b/pkg/config/config.go index 9c2d573..6901a5d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/rand" + "encoding/hex" "os" "time" @@ -126,6 +128,9 @@ type SecurityConfig struct { AuthFailureThreshold int `yaml:"auth_failure_threshold" json:"auth_failure_threshold"` UniquePathsThreshold int `yaml:"unique_paths_threshold" json:"unique_paths_threshold"` RepeatedHitsThreshold int `yaml:"repeated_hits_threshold" json:"repeated_hits_threshold"` + + // Internal API token for nginx-to-agent communication (auto-generated if empty) + InternalAPIToken string `yaml:"internal_api_token" json:"-"` } func FindConfigPath(providedPath string) string { @@ -268,6 +273,12 @@ func setDefaults(cfg *Config) { if cfg.Security.RepeatedHitsThreshold == 0 { cfg.Security.RepeatedHitsThreshold = 30 } + if cfg.Security.InternalAPIToken == "" { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err == nil { + cfg.Security.InternalAPIToken = hex.EncodeToString(bytes) + } + } } func Save(cfg *Config, path string) error { diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index 50b2812..79303f9 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -9,6 +9,7 @@ local _M = {} -- Configuration (injected by agent during deployment) local AGENT_IP = "{{.AgentIP}}" local AGENT_PORT = {{.AgentPort}} +local INTERNAL_TOKEN = "{{.InternalAPIToken}}" -- Blocked IPs cache settings local BLOCKED_IPS_CACHE_TTL = 30 -- seconds @@ -110,9 +111,10 @@ function _M.refresh_blocked_ips() local res, req_err = httpc:request({ method = "GET", - path = "/api/security/blocked-ips", + path = "/api/_internal/blocked-ips", headers = { ["Host"] = AGENT_IP .. ":" .. AGENT_PORT, + ["X-Internal-Token"] = INTERNAL_TOKEN, } }) diff --git a/templates/templates.go b/templates/templates.go index 440ee8b..f838bbf 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -92,12 +92,13 @@ func GetNginxSecurityLua() ([]byte, error) { // LuaTemplateData contains the data for Lua template processing type LuaTemplateData struct { - AgentIP string - AgentPort int + AgentIP string + AgentPort int + InternalAPIToken string } // GetNginxSecurityLuaWithConfig returns the security.lua template processed with agent config -func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int) ([]byte, error) { +func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int, internalAPIToken string) ([]byte, error) { content, err := FS.ReadFile("infra/nginx/lua/security.lua") if err != nil { return nil, err @@ -110,8 +111,9 @@ func GetNginxSecurityLuaWithConfig(agentIP string, agentPort int) ([]byte, error var buf bytes.Buffer data := LuaTemplateData{ - AgentIP: agentIP, - AgentPort: agentPort, + AgentIP: agentIP, + AgentPort: agentPort, + InternalAPIToken: internalAPIToken, } if err := tmpl.Execute(&buf, data); err != nil { diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index 95e8811..982fe7e 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -10,6 +10,7 @@ local _M = {} -- Configuration via environment variable (test-specific) local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" +local INTERNAL_TOKEN = os.getenv("FLATRUN_INTERNAL_TOKEN") or "" -- Blocked IPs cache settings local BLOCKED_IPS_CACHE_TTL = 30 -- seconds @@ -47,8 +48,11 @@ function _M.refresh_blocked_ips() local httpc = http.new() httpc:set_timeout(3000) - local res, err = httpc:request_uri(AGENT_URL .. "/api/security/blocked-ips", { + local res, err = httpc:request_uri(AGENT_URL .. "/api/_internal/blocked-ips", { method = "GET", + headers = { + ["X-Internal-Token"] = INTERNAL_TOKEN, + }, }) if not res then