Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 67 additions & 8 deletions internal/api/security_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -611,6 +624,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 {
Expand Down Expand Up @@ -670,6 +689,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")
}
Comment on lines +693 to +697
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistent with previous duration parsing suggestions, explicitly handle errors for DetectionWindow to provide clear feedback to the API consumer.

Suggested change
if req.DetectionWindow != "" {
if d, err := time.ParseDuration(req.DetectionWindow); err == nil {
s.config.Security.DetectionWindow = d
updatedFields = append(updatedFields, "detection_window")
}
if req.DetectionWindow != "" {
d, err := time.ParseDuration(req.DetectionWindow)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid detection_window duration format"})
return
}
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

Expand Down Expand Up @@ -713,16 +755,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)
Expand Down
12 changes: 12 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment on lines +95 to +102
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetDetectorThresholds call here correctly initializes the security manager on startup. However, the exact same call is also made in updateSecuritySettings. While not strictly an error, it's slightly redundant. Consider if the initial setup should just apply default values (or values from the config file at startup) and the API handler should be the sole source of runtime updates. If the manager is re-initialized or config reloaded without an API call, this is necessary. Current approach is defensive.

Suggested change
securityManager.SetDetectorThresholds(
cfg.Security.RateThreshold,
cfg.Security.NotFoundThreshold,
cfg.Security.AuthFailureThreshold,
cfg.Security.UniquePathsThreshold,
cfg.Security.RepeatedHitsThreshold,
cfg.Security.DetectionWindow,
)
// Thresholds are applied dynamically via updateSecuritySettings, ensuring consistency.
// Initial values are sourced directly from config.Security.

nginxConfigPath := cfg.Nginx.ConfigPath
if nginxConfigPath == "" {
nginxConfigPath = filepath.Join(cfg.DeploymentsPath, "nginx", "conf.d")
Expand Down Expand Up @@ -293,6 +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)

// Internal nginx endpoint - token-authenticated for blocked IPs
api.GET("/_internal/blocked-ips", s.listBlockedIPsInternal)
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/infra/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/security/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package config

import (
"crypto/rand"
"encoding/hex"
"os"
"time"

Expand Down Expand Up @@ -119,6 +121,16 @@ 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"`

// Internal API token for nginx-to-agent communication (auto-generated if empty)
InternalAPIToken string `yaml:"internal_api_token" json:"-"`
}

func FindConfigPath(providedPath string) string {
Expand Down Expand Up @@ -245,6 +257,28 @@ 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
}
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 {
Expand Down
4 changes: 3 additions & 1 deletion templates/infra/nginx/lua/security.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
})

Expand Down
12 changes: 7 additions & 5 deletions templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion test/e2e/nginx/lua/security.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading