From c2bf25646494ad1c1a960e0939e4b14cd7e1b0f8 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 13:06:08 +0100 Subject: [PATCH 1/4] fix(security): Apply blocked IPs to nginx immediately via Lua shared dict Previously, blocked IPs were stored in the database but never applied to nginx. Now IPs are enforced instantly via nginx's access_by_lua checking the shared_dict, with immediate notification on block/unblock. Signed-off-by: nfebe --- internal/api/security_handlers.go | 94 ++++++++++++- internal/security/manager.go | 31 ++++- templates/infra/nginx/lua/security.lua | 113 ++++++++++++++++ templates/infra/nginx/nginx.lua.conf | 30 +++++ test/e2e/nginx/lua/nginx.conf | 30 +++++ test/e2e/nginx/lua/security.lua | 177 +++++++++++++++++++++++++ 6 files changed, 465 insertions(+), 10 deletions(-) diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index 7bfa6e2..527000e 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -1,7 +1,11 @@ package api import ( + "encoding/json" + "fmt" + "log" "net/http" + "os/exec" "strconv" "time" @@ -24,18 +28,29 @@ func (s *Server) ingestSecurityEvent(c *gin.Context) { return } - secEvent, err := s.securityManager.IngestEvent(&event) + result, err := s.securityManager.IngestEvent(&event) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if secEvent == nil { + if result.Event == nil { c.JSON(http.StatusOK, gin.H{"processed": false, "reason": "Event not security-relevant or IP blocked"}) return } - c.JSON(http.StatusCreated, gin.H{"processed": true, "event": secEvent}) + // If an IP was auto-blocked, notify nginx immediately + if result.AutoBlocked { + if err := s.notifyNginxBlockIP(result.BlockedIP, result.BlockTTL); err != nil { + log.Printf("Warning: failed to notify nginx about auto-blocked IP %s: %v", result.BlockedIP, err) + } + } + + c.JSON(http.StatusCreated, gin.H{ + "processed": true, + "event": result.Event, + "auto_blocked": result.AutoBlocked, + }) } // getSecurityStats returns security statistics @@ -198,6 +213,11 @@ func (s *Server) blockIP(c *gin.Context) { return } + // Notify nginx to immediately block the IP + if err := s.notifyNginxBlockIP(req.IP, req.Duration); err != nil { + log.Printf("Warning: failed to notify nginx about blocked IP %s: %v", req.IP, err) + } + c.JSON(http.StatusCreated, gin.H{"id": id, "message": "IP blocked successfully"}) } @@ -219,6 +239,11 @@ func (s *Server) unblockIP(c *gin.Context) { return } + // Notify nginx to immediately unblock the IP + if err := s.notifyNginxUnblockIP(ip); err != nil { + log.Printf("Warning: failed to notify nginx about unblocked IP %s: %v", ip, err) + } + c.JSON(http.StatusOK, gin.H{"message": "IP unblocked successfully"}) } @@ -699,6 +724,69 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { c.JSON(http.StatusOK, result) } +// notifyNginxBlockIP notifies nginx to immediately add an IP to its blocked list +func (s *Server) notifyNginxBlockIP(ip string, ttlSeconds int) error { + if s.config.Nginx.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + if ttlSeconds <= 0 { + ttlSeconds = 86400 * 365 // 1 year for permanent blocks + } + + payload := map[string]interface{}{ + "ip": ip, + "ttl": ttlSeconds, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + curlCmd := fmt.Sprintf( + `curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/block-ip`, + string(jsonPayload), + ) + + cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err) + } + + log.Printf("Notified nginx to block IP %s (ttl=%ds): %s", ip, ttlSeconds, string(output)) + return nil +} + +// notifyNginxUnblockIP notifies nginx to immediately remove an IP from its blocked list +func (s *Server) notifyNginxUnblockIP(ip string) error { + if s.config.Nginx.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + payload := map[string]interface{}{ + "ip": ip, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + curlCmd := fmt.Sprintf( + `curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/unblock-ip`, + string(jsonPayload), + ) + + cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err) + } + + log.Printf("Notified nginx to unblock IP %s: %s", ip, string(output)) + return nil +} + // refreshSecurityScripts regenerates Lua scripts with correct agent IP and reloads nginx func (s *Server) refreshSecurityScripts(c *gin.Context) { if !s.config.Security.Enabled { diff --git a/internal/security/manager.go b/internal/security/manager.go index 6491742..8bb9604 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -37,24 +37,34 @@ func (m *Manager) Close() error { return m.db.Close() } +// IngestResult contains the result of event ingestion +type IngestResult struct { + Event *SecurityEvent + AutoBlocked bool + BlockedIP string + BlockTTL int // TTL in seconds for the block +} + // IngestEvent processes an incoming event from nginx and stores it -func (m *Manager) IngestEvent(event *IngestEvent) (*SecurityEvent, error) { +func (m *Manager) IngestEvent(event *IngestEvent) (*IngestResult, error) { m.mu.Lock() defer m.mu.Unlock() + result := &IngestResult{} + // Check if IP is blocked - if so, don't process blocked, err := m.db.IsIPBlocked(event.SourceIP) if err != nil { return nil, err } if blocked { - return nil, nil + return result, nil } // Classify the event secEvent := m.detector.Classify(event) if secEvent == nil { - return nil, nil + return result, nil } // Store the event @@ -63,14 +73,21 @@ func (m *Manager) IngestEvent(event *IngestEvent) (*SecurityEvent, error) { return nil, err } secEvent.ID = id + result.Event = secEvent - // Check if we should auto-block the IP (best-effort, ignore errors) + // Check if we should auto-block the IP if m.detector.ShouldAutoBlock(event.SourceIP, secEvent) { - expiresAt := time.Now().Add(24 * time.Hour) - _, _ = m.db.BlockIP(event.SourceIP, "Auto-blocked due to suspicious activity", &expiresAt, true) + blockDuration := 24 * time.Hour + expiresAt := time.Now().Add(blockDuration) + _, err := m.db.BlockIP(event.SourceIP, "Auto-blocked due to suspicious activity", &expiresAt, true) + if err == nil { + result.AutoBlocked = true + result.BlockedIP = event.SourceIP + result.BlockTTL = int(blockDuration.Seconds()) + } } - return secEvent, nil + return result, nil } // GetEvents retrieves events with optional filtering diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index e9116f4..2627aaa 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -292,4 +292,117 @@ function _M.check_rate_limit(key, limit, window) return false end +-- Internal API handlers for immediate IP blocking + +-- Handle block IP request from agent +function _M.handle_block_ip_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.status = 400 + ngx.say('{"error": "No body provided"}') + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.status = 400 + ngx.say('{"error": "Invalid JSON: ' .. (err or "unknown") .. '"}') + return + end + + local ip = data.ip + local ttl = data.ttl or 86400 -- default 24 hours + + if not ip then + ngx.status = 400 + ngx.say('{"error": "IP address required"}') + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.status = 500 + ngx.say('{"error": "Shared dict not available"}') + return + end + + local ok, set_err = dict:set("ip:" .. ip, true, ttl) + if not ok then + ngx.status = 500 + ngx.say('{"error": "Failed to block IP: ' .. (set_err or "unknown") .. '"}') + return + end + + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "ip": "' .. ip .. '", "ttl": ' .. ttl .. '}') +end + +-- Handle unblock IP request from agent +function _M.handle_unblock_ip_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.status = 400 + ngx.say('{"error": "No body provided"}') + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.status = 400 + ngx.say('{"error": "Invalid JSON: ' .. (err or "unknown") .. '"}') + return + end + + local ip = data.ip + if not ip then + ngx.status = 400 + ngx.say('{"error": "IP address required"}') + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.status = 500 + ngx.say('{"error": "Shared dict not available"}') + return + end + + dict:delete("ip:" .. ip) + + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "ip": "' .. ip .. '"}') +end + +-- Handle refresh request - force full cache refresh +function _M.handle_refresh_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + -- Perform synchronous refresh + _M.refresh_blocked_ips() + + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "message": "Cache refreshed"}') +end + return _M diff --git a/templates/infra/nginx/nginx.lua.conf b/templates/infra/nginx/nginx.lua.conf index ddd8892..830cc66 100644 --- a/templates/infra/nginx/nginx.lua.conf +++ b/templates/infra/nginx/nginx.lua.conf @@ -73,4 +73,34 @@ http { # Include virtual hosts include /etc/nginx/conf.d/*.conf; + + # Internal API server for security operations (not exposed externally) + server { + listen 127.0.0.1:8081; + server_name localhost; + + location /_internal/security/block-ip { + content_by_lua_block { + security.handle_block_ip_request() + } + } + + location /_internal/security/unblock-ip { + content_by_lua_block { + security.handle_unblock_ip_request() + } + } + + location /_internal/security/refresh-blocked-ips { + content_by_lua_block { + security.handle_refresh_request() + } + } + + location /_internal/health { + content_by_lua_block { + ngx.say("OK") + } + } + } } diff --git a/test/e2e/nginx/lua/nginx.conf b/test/e2e/nginx/lua/nginx.conf index f26f670..82aaf67 100644 --- a/test/e2e/nginx/lua/nginx.conf +++ b/test/e2e/nginx/lua/nginx.conf @@ -56,4 +56,34 @@ http { # Default configs include /etc/nginx/conf.d/*.conf; + + # Internal API server for security operations (not exposed externally) + server { + listen 127.0.0.1:8081; + server_name localhost; + + location /_internal/security/block-ip { + content_by_lua_block { + security.handle_block_ip_request() + } + } + + location /_internal/security/unblock-ip { + content_by_lua_block { + security.handle_unblock_ip_request() + } + } + + location /_internal/security/refresh-blocked-ips { + content_by_lua_block { + security.handle_refresh_request() + } + } + + location /_internal/health { + content_by_lua_block { + ngx.say("OK") + } + } + } } diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index cb106af..0e5a3f1 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -9,6 +9,82 @@ local _M = {} -- Configuration (will be set by the agent) local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" +-- Blocked IPs cache settings +local BLOCKED_IPS_CACHE_TTL = 30 -- seconds +local BLOCKED_IPS_LAST_FETCH = "blocked_ips_last_fetch" + +-- Check if an IP is blocked (with caching) +function _M.is_blocked(ip) + if not ip then return false end + + local dict = ngx.shared.blocked_ips + if not dict then return false end + + local is_blocked = dict:get("ip:" .. ip) + if is_blocked ~= nil then + return is_blocked + end + + local last_fetch = dict:get(BLOCKED_IPS_LAST_FETCH) or 0 + local now = ngx.time() + + if now - last_fetch > BLOCKED_IPS_CACHE_TTL then + ngx.timer.at(0, function() + _M.refresh_blocked_ips() + end) + end + + return false +end + +-- Fetch blocked IPs from agent API and cache them +function _M.refresh_blocked_ips() + local dict = ngx.shared.blocked_ips + if not dict then return end + + local httpc = http.new() + httpc:set_timeout(3000) + + local res, err = httpc:request_uri(AGENT_URL .. "/api/security/blocked-ips", { + method = "GET", + }) + + if not res then + ngx.log(ngx.ERR, "Failed to fetch blocked IPs: ", err) + return + end + + if res.status ~= 200 then + ngx.log(ngx.ERR, "Blocked IPs API returned status: ", res.status) + return + end + + local data, decode_err = cjson.decode(res.body) + if not data then + ngx.log(ngx.ERR, "Failed to decode blocked IPs response: ", decode_err) + return + end + + dict:flush_all() + dict:set(BLOCKED_IPS_LAST_FETCH, ngx.time()) + + local blocked_ips = data.blocked_ips or {} + for _, entry in ipairs(blocked_ips) do + if entry.ip then + dict:set("ip:" .. entry.ip, true, BLOCKED_IPS_CACHE_TTL * 2) + end + end + + ngx.log(ngx.INFO, "Refreshed blocked IPs cache: ", #blocked_ips, " IPs") +end + +-- Initialize blocked IPs cache on worker start +function _M.init_blocked_ips() + ngx.timer.at(0, function() + _M.refresh_blocked_ips() + end) +end + -- Suspicious paths patterns local suspicious_patterns = { "%.env", @@ -177,4 +253,105 @@ function _M.check_rate_limit(key, limit, window) return false end +-- Internal API handlers for immediate IP blocking + +function _M.handle_block_ip_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.status = 400 + ngx.say('{"error": "No body provided"}') + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.status = 400 + ngx.say('{"error": "Invalid JSON"}') + return + end + + local ip = data.ip + local ttl = data.ttl or 86400 + + if not ip then + ngx.status = 400 + ngx.say('{"error": "IP address required"}') + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.status = 500 + ngx.say('{"error": "Shared dict not available"}') + return + end + + dict:set("ip:" .. ip, true, ttl) + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "ip": "' .. ip .. '"}') +end + +function _M.handle_unblock_ip_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.status = 400 + ngx.say('{"error": "No body provided"}') + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.status = 400 + ngx.say('{"error": "Invalid JSON"}') + return + end + + local ip = data.ip + if not ip then + ngx.status = 400 + ngx.say('{"error": "IP address required"}') + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.status = 500 + ngx.say('{"error": "Shared dict not available"}') + return + end + + dict:delete("ip:" .. ip) + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "ip": "' .. ip .. '"}') +end + +function _M.handle_refresh_request() + if ngx.req.get_method() ~= "POST" then + ngx.status = 405 + ngx.say('{"error": "Method not allowed"}') + return + end + + _M.refresh_blocked_ips() + ngx.status = 200 + ngx.header["Content-Type"] = "application/json" + ngx.say('{"success": true, "message": "Cache refreshed"}') +end + return _M From 973e2c9f4080b860a337bbff41c8caa4f778e834 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 13:29:20 +0100 Subject: [PATCH 2/4] fix(security): Improve autoblock detection for real attack patterns Autoblock now triggers on: - 10+ 404 responses in 2 min (path probing) - 5+ auth failures (401/403) in 2 min - 20+ unique paths tried in 2 min (scanning) - 30+ hits to same path in 2 min (hammering) - Known scanner user agents - High request rate (60+ req/2min) Signed-off-by: nfebe --- internal/security/detector.go | 90 +++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/internal/security/detector.go b/internal/security/detector.go index eb8ffd9..a474e12 100644 --- a/internal/security/detector.go +++ b/internal/security/detector.go @@ -11,38 +11,50 @@ type Detector struct { mu sync.RWMutex // Thresholds - rateWindowDuration time.Duration - rateThreshold int - autoBlockThreshold int + windowDuration time.Duration + rateThreshold int // high request rate + notFoundThreshold int // 404 responses + authFailureThreshold int // 401/403 responses + uniquePathsThreshold int // scanning many different paths + repeatedHitsThreshold int // hammering same path } type requestWindow struct { - count int - windowEnd time.Time + count int + notFoundHits int // 404 responses + authFailures int // 401/403 responses + pathHits map[string]int // path -> hit count + windowEnd time.Time } func NewDetector() *Detector { return &Detector{ - ipRequestCount: make(map[string]*requestWindow), - rateWindowDuration: time.Minute, - rateThreshold: 100, - autoBlockThreshold: 50, + ipRequestCount: make(map[string]*requestWindow), + windowDuration: 2 * time.Minute, + rateThreshold: 60, // 60 requests in 2 min + notFoundThreshold: 10, // 10 404s in 2 min + authFailureThreshold: 5, // 5 auth failures in 2 min + uniquePathsThreshold: 20, // 20 different paths in 2 min + repeatedHitsThreshold: 30, // 30 hits to same path in 2 min } } // SetThresholds configures detection thresholds -func (d *Detector) SetThresholds(rateThreshold, autoBlockThreshold int, windowDuration time.Duration) { +func (d *Detector) SetThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold int, windowDuration time.Duration) { d.mu.Lock() defer d.mu.Unlock() d.rateThreshold = rateThreshold - d.autoBlockThreshold = autoBlockThreshold - d.rateWindowDuration = windowDuration + d.notFoundThreshold = notFoundThreshold + d.authFailureThreshold = authFailureThreshold + d.uniquePathsThreshold = uniquePathsThreshold + d.repeatedHitsThreshold = repeatedHitsThreshold + d.windowDuration = windowDuration } // Classify analyzes an incoming event and creates a SecurityEvent if it's security-relevant func (d *Detector) Classify(event *IngestEvent) *SecurityEvent { - // Track request rate - highRate := d.trackRequestRate(event.SourceIP) + // Track request behavior + highRate := d.trackRequest(event.SourceIP, event) var eventType, severity, message string @@ -118,8 +130,8 @@ func (d *Detector) Classify(event *IngestEvent) *SecurityEvent { } } -// trackRequestRate tracks request rate per IP and returns true if rate is too high -func (d *Detector) trackRequestRate(ip string) bool { +// trackRequest tracks request behavior per IP and returns true if rate is too high +func (d *Detector) trackRequest(ip string, event *IngestEvent) bool { d.mu.Lock() defer d.mu.Unlock() @@ -129,12 +141,27 @@ func (d *Detector) trackRequestRate(ip string) bool { if !exists || now.After(window.windowEnd) { d.ipRequestCount[ip] = &requestWindow{ count: 1, - windowEnd: now.Add(d.rateWindowDuration), + pathHits: make(map[string]int), + windowEnd: now.Add(d.windowDuration), } - return false + window = d.ipRequestCount[ip] + } else { + window.count++ + } + + // Track hits per path + window.pathHits[event.RequestPath]++ + + // Track 404 responses (probing for files/paths) + if event.StatusCode == 404 { + window.notFoundHits++ + } + + // Track auth failures (401, 403) + if event.StatusCode == 401 || event.StatusCode == 403 { + window.authFailures++ } - window.count++ return window.count > d.rateThreshold } @@ -150,15 +177,36 @@ func (d *Detector) ShouldAutoBlock(ip string, event *SecurityEvent) bool { return true } - // Check for repeated critical events d.mu.RLock() window, exists := d.ipRequestCount[ip] d.mu.RUnlock() - if exists && window.count > d.autoBlockThreshold && event.Severity == SeverityCritical { + if !exists { + return false + } + + // Auto-block after too many 404s (probing for files/paths) + if window.notFoundHits >= d.notFoundThreshold { return true } + // Auto-block after too many auth failures + if window.authFailures >= d.authFailureThreshold { + return true + } + + // Auto-block if trying too many unique paths (scanning) + if len(window.pathHits) >= d.uniquePathsThreshold { + return true + } + + // Auto-block if hammering same path repeatedly + for _, hits := range window.pathHits { + if hits >= d.repeatedHitsThreshold { + return true + } + } + return false } From 5446c87b6e85016fb39fb3a0459f5b12c6aeb0c5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 13:40:06 +0100 Subject: [PATCH 3/4] refactor(security): Address code review feedback - Use configurable AutoBlockDuration instead of hardcoded 24h - Add sync notice comment to e2e test Lua file - Use cjson.encode for all Lua JSON responses Signed-off-by: nfebe --- internal/api/security_handlers.go | 2 +- internal/security/manager.go | 7 ++- templates/infra/nginx/lua/security.lua | 57 +++++++++---------------- test/e2e/nginx/lua/security.lua | 59 +++++++++++--------------- 4 files changed, 50 insertions(+), 75 deletions(-) diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index 527000e..172e26b 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -28,7 +28,7 @@ func (s *Server) ingestSecurityEvent(c *gin.Context) { return } - result, err := s.securityManager.IngestEvent(&event) + result, err := s.securityManager.IngestEvent(&event, s.config.Security.AutoBlockDuration) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/security/manager.go b/internal/security/manager.go index 8bb9604..05a3105 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -46,7 +46,7 @@ type IngestResult struct { } // IngestEvent processes an incoming event from nginx and stores it -func (m *Manager) IngestEvent(event *IngestEvent) (*IngestResult, error) { +func (m *Manager) IngestEvent(event *IngestEvent, autoBlockDuration time.Duration) (*IngestResult, error) { m.mu.Lock() defer m.mu.Unlock() @@ -77,13 +77,12 @@ func (m *Manager) IngestEvent(event *IngestEvent) (*IngestResult, error) { // Check if we should auto-block the IP if m.detector.ShouldAutoBlock(event.SourceIP, secEvent) { - blockDuration := 24 * time.Hour - expiresAt := time.Now().Add(blockDuration) + expiresAt := time.Now().Add(autoBlockDuration) _, err := m.db.BlockIP(event.SourceIP, "Auto-blocked due to suspicious activity", &expiresAt, true) if err == nil { result.AutoBlocked = true result.BlockedIP = event.SourceIP - result.BlockTTL = int(blockDuration.Seconds()) + result.BlockTTL = int(autoBlockDuration.Seconds()) } } diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index 2627aaa..c5554c8 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -294,26 +294,29 @@ end -- Internal API handlers for immediate IP blocking +local function json_response(status, data) + ngx.status = status + ngx.header["Content-Type"] = "application/json" + ngx.say(cjson.encode(data)) +end + -- Handle block IP request from agent function _M.handle_block_ip_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end ngx.req.read_body() local body = ngx.req.get_body_data() if not body then - ngx.status = 400 - ngx.say('{"error": "No body provided"}') + json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - ngx.status = 400 - ngx.say('{"error": "Invalid JSON: ' .. (err or "unknown") .. '"}') + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end @@ -321,88 +324,70 @@ function _M.handle_block_ip_request() local ttl = data.ttl or 86400 -- default 24 hours if not ip then - ngx.status = 400 - ngx.say('{"error": "IP address required"}') + json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then - ngx.status = 500 - ngx.say('{"error": "Shared dict not available"}') + json_response(500, {error = "Shared dict not available"}) return end local ok, set_err = dict:set("ip:" .. ip, true, ttl) if not ok then - ngx.status = 500 - ngx.say('{"error": "Failed to block IP: ' .. (set_err or "unknown") .. '"}') + json_response(500, {error = "Failed to block IP: " .. (set_err or "unknown")}) return end - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "ip": "' .. ip .. '", "ttl": ' .. ttl .. '}') + json_response(200, {success = true, ip = ip, ttl = ttl}) end -- Handle unblock IP request from agent function _M.handle_unblock_ip_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end ngx.req.read_body() local body = ngx.req.get_body_data() if not body then - ngx.status = 400 - ngx.say('{"error": "No body provided"}') + json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - ngx.status = 400 - ngx.say('{"error": "Invalid JSON: ' .. (err or "unknown") .. '"}') + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end local ip = data.ip if not ip then - ngx.status = 400 - ngx.say('{"error": "IP address required"}') + json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then - ngx.status = 500 - ngx.say('{"error": "Shared dict not available"}') + json_response(500, {error = "Shared dict not available"}) return end dict:delete("ip:" .. ip) - - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "ip": "' .. ip .. '"}') + json_response(200, {success = true, ip = ip}) end -- Handle refresh request - force full cache refresh function _M.handle_refresh_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end - -- Perform synchronous refresh _M.refresh_blocked_ips() - - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "message": "Cache refreshed"}') + json_response(200, {success = true, message = "Cache refreshed"}) end return _M diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index 0e5a3f1..d09f524 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -1,12 +1,14 @@ --- FlatRun Security Event Capture --- This script captures security-relevant events and sends them to the agent API +-- FlatRun Security Event Capture (E2E Test Version) +-- NOTE: This is a simplified version for e2e testing that uses environment variables. +-- The production template is at: templates/infra/nginx/lua/security.lua +-- Keep core functionality in sync with the template. local cjson = require "cjson.safe" local http = require "resty.http" local _M = {} --- Configuration (will be set by the agent) +-- Configuration via environment variable (test-specific) local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" -- Blocked IPs cache settings @@ -255,25 +257,28 @@ end -- Internal API handlers for immediate IP blocking +local function json_response(status, data) + ngx.status = status + ngx.header["Content-Type"] = "application/json" + ngx.say(cjson.encode(data)) +end + function _M.handle_block_ip_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end ngx.req.read_body() local body = ngx.req.get_body_data() if not body then - ngx.status = 400 - ngx.say('{"error": "No body provided"}') + json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - ngx.status = 400 - ngx.say('{"error": "Invalid JSON"}') + json_response(400, {error = "Invalid JSON"}) return end @@ -281,77 +286,63 @@ function _M.handle_block_ip_request() local ttl = data.ttl or 86400 if not ip then - ngx.status = 400 - ngx.say('{"error": "IP address required"}') + json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then - ngx.status = 500 - ngx.say('{"error": "Shared dict not available"}') + json_response(500, {error = "Shared dict not available"}) return end dict:set("ip:" .. ip, true, ttl) - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "ip": "' .. ip .. '"}') + json_response(200, {success = true, ip = ip}) end function _M.handle_unblock_ip_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end ngx.req.read_body() local body = ngx.req.get_body_data() if not body then - ngx.status = 400 - ngx.say('{"error": "No body provided"}') + json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - ngx.status = 400 - ngx.say('{"error": "Invalid JSON"}') + json_response(400, {error = "Invalid JSON"}) return end local ip = data.ip if not ip then - ngx.status = 400 - ngx.say('{"error": "IP address required"}') + json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then - ngx.status = 500 - ngx.say('{"error": "Shared dict not available"}') + json_response(500, {error = "Shared dict not available"}) return end dict:delete("ip:" .. ip) - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "ip": "' .. ip .. '"}') + json_response(200, {success = true, ip = ip}) end function _M.handle_refresh_request() if ngx.req.get_method() ~= "POST" then - ngx.status = 405 - ngx.say('{"error": "Method not allowed"}') + json_response(405, {error = "Method not allowed"}) return end _M.refresh_blocked_ips() - ngx.status = 200 - ngx.header["Content-Type"] = "application/json" - ngx.say('{"success": true, "message": "Cache refreshed"}') + json_response(200, {success = true, message = "Cache refreshed"}) end return _M From 0fae8e43d479d3ca7f433c09a00091a7250c93ea Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 23 Dec 2025 13:56:56 +0100 Subject: [PATCH 4/4] feat(security): Add health checks and enhanced logging for IP blocking - Add nginx_internal_api_reachable health check to verify port 8081 - Add nginx_conf_has_blocked_ips_dict, nginx_conf_has_ip_blocking, nginx_conf_has_internal_api to critical health checks - Enhance Go ingestSecurityEvent logging with client IP and event context - Enhance Lua handlers with detailed logging (client IP, body preview, operation context) for better debuggability - Sync e2e test security.lua with template changes Signed-off-by: nfebe --- internal/api/security_handlers.go | 4 ++ internal/infra/manager.go | 55 ++++++++++++++++++++++++++ templates/infra/nginx/lua/security.lua | 21 ++++++++++ test/e2e/nginx/lua/security.lua | 35 ++++++++++++++-- 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index 172e26b..f16b453 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -24,12 +24,16 @@ func (s *Server) ingestSecurityEvent(c *gin.Context) { var event security.IngestEvent if err := c.ShouldBindJSON(&event); err != nil { + clientIP := c.ClientIP() + log.Printf("Security ingest: failed to parse JSON from %s: %v", clientIP, err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := s.securityManager.IngestEvent(&event, s.config.Security.AutoBlockDuration) if err != nil { + log.Printf("Security ingest: failed to process event from IP %s (path=%s, method=%s): %v", + event.SourceIP, event.RequestPath, event.RequestMethod, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/infra/manager.go b/internal/infra/manager.go index 15f5024..72f65ab 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -686,11 +686,41 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { result.Checks["nginx_conf_has_global_traffic_logging"] = false result.Issues = append(result.Issues, "nginx.conf does not have global traffic logging enabled") } + + // Check for IP blocking shared dict + if strings.Contains(contentStr, "lua_shared_dict blocked_ips") { + result.Checks["nginx_conf_has_blocked_ips_dict"] = true + } else { + result.Checks["nginx_conf_has_blocked_ips_dict"] = false + result.Issues = append(result.Issues, "nginx.conf missing lua_shared_dict blocked_ips for IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking support") + } + + // Check for IP blocking access check + if strings.Contains(contentStr, "access_by_lua_block") && strings.Contains(contentStr, "security.is_blocked") { + result.Checks["nginx_conf_has_ip_blocking"] = true + } else { + result.Checks["nginx_conf_has_ip_blocking"] = false + result.Issues = append(result.Issues, "nginx.conf missing access_by_lua_block for IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking") + } + + // Check for internal API server block + if strings.Contains(contentStr, "listen 127.0.0.1:8081") { + result.Checks["nginx_conf_has_internal_api"] = true + } else { + result.Checks["nginx_conf_has_internal_api"] = false + result.Issues = append(result.Issues, "nginx.conf missing internal API server for instant IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with internal API") + } } else { result.Checks["nginx_conf_exists"] = false result.Checks["nginx_conf_has_lua_init"] = false result.Checks["nginx_conf_has_traffic_module"] = false result.Checks["nginx_conf_has_global_traffic_logging"] = false + result.Checks["nginx_conf_has_blocked_ips_dict"] = false + result.Checks["nginx_conf_has_ip_blocking"] = false + result.Checks["nginx_conf_has_internal_api"] = false result.Issues = append(result.Issues, "nginx.conf does not exist at "+nginxConfPath) result.Recommendations = append(result.Recommendations, "Enable realtime capture in Security settings") } @@ -815,6 +845,16 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { result.Recommendations = append(result.Recommendations, "3. Use POST /api/security/refresh to regenerate scripts with correct IP") } + + // Check 9: Internal API (port 8081) is reachable for instant IP blocking + internalAPIReachable := m.checkNginxInternalAPIReachable() + result.Checks["nginx_internal_api_reachable"] = internalAPIReachable + if !internalAPIReachable { + result.Issues = append(result.Issues, + "Nginx internal API (port 8081) is not responding - instant IP blocking will not work") + result.Recommendations = append(result.Recommendations, + "Check nginx error logs for Lua errors, ensure nginx.conf has internal server block on 127.0.0.1:8081") + } } // Determine overall status @@ -824,11 +864,15 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { "traffic_lua_exists", "traffic_lua_ip_injected", "nginx_conf_has_lua_init", + "nginx_conf_has_blocked_ips_dict", + "nginx_conf_has_ip_blocking", + "nginx_conf_has_internal_api", "nginx_container_running", "nginx_lua_module_loaded", "nginx_conf_mounted", "lua_directory_mounted", "nginx_can_reach_agent", + "nginx_internal_api_reachable", "vhosts_have_security_hook", } @@ -990,6 +1034,17 @@ func (m *Manager) checkNginxCanReachAgent(agentIP string, agentPort int) bool { return strings.Contains(string(output), "yes") } +func (m *Manager) checkNginxInternalAPIReachable() bool { + testCmd := "curl -s --connect-timeout 2 --max-time 5 -X POST http://127.0.0.1:8081/_internal/security/refresh-blocked-ips 2>/dev/null | grep -q success && echo yes || echo no" + + cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", testCmd) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), "yes") +} + // securityVolumeMounts are added when security is enabled and removed when disabled var securityVolumeMounts = []string{ "./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro", diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index c5554c8..50b2812 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -302,7 +302,10 @@ end -- Handle block IP request from agent function _M.handle_block_ip_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "block-ip: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end @@ -310,12 +313,14 @@ function _M.handle_block_ip_request() ngx.req.read_body() local body = ngx.req.get_body_data() if not body then + ngx.log(ngx.ERR, "block-ip: no body from ", client_ip) json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then + ngx.log(ngx.ERR, "block-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end @@ -324,28 +329,35 @@ function _M.handle_block_ip_request() local ttl = data.ttl or 86400 -- default 24 hours if not ip then + ngx.log(ngx.ERR, "block-ip: missing IP in request from ", client_ip) json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then + ngx.log(ngx.ERR, "block-ip: shared dict not available") json_response(500, {error = "Shared dict not available"}) return end local ok, set_err = dict:set("ip:" .. ip, true, ttl) if not ok then + ngx.log(ngx.ERR, "block-ip: failed to set IP ", ip, " in dict: ", set_err) json_response(500, {error = "Failed to block IP: " .. (set_err or "unknown")}) return end + ngx.log(ngx.INFO, "block-ip: blocked ", ip, " for ", ttl, "s") json_response(200, {success = true, ip = ip, ttl = ttl}) end -- Handle unblock IP request from agent function _M.handle_unblock_ip_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "unblock-ip: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end @@ -353,39 +365,48 @@ function _M.handle_unblock_ip_request() ngx.req.read_body() local body = ngx.req.get_body_data() if not body then + ngx.log(ngx.ERR, "unblock-ip: no body from ", client_ip) json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then + ngx.log(ngx.ERR, "unblock-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end local ip = data.ip if not ip then + ngx.log(ngx.ERR, "unblock-ip: missing IP in request from ", client_ip) json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then + ngx.log(ngx.ERR, "unblock-ip: shared dict not available") json_response(500, {error = "Shared dict not available"}) return end dict:delete("ip:" .. ip) + ngx.log(ngx.INFO, "unblock-ip: unblocked ", ip) json_response(200, {success = true, ip = ip}) end -- Handle refresh request - force full cache refresh function _M.handle_refresh_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "refresh: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end + ngx.log(ngx.INFO, "refresh: refreshing blocked IPs cache") _M.refresh_blocked_ips() json_response(200, {success = true, message = "Cache refreshed"}) end diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index d09f524..95e8811 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -256,6 +256,7 @@ function _M.check_rate_limit(key, limit, window) end -- Internal API handlers for immediate IP blocking +-- NOTE: Keep in sync with templates/infra/nginx/lua/security.lua local function json_response(status, data) ngx.status = status @@ -264,7 +265,10 @@ local function json_response(status, data) end function _M.handle_block_ip_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "block-ip: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end @@ -272,13 +276,15 @@ function _M.handle_block_ip_request() ngx.req.read_body() local body = ngx.req.get_body_data() if not body then + ngx.log(ngx.ERR, "block-ip: no body from ", client_ip) json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - json_response(400, {error = "Invalid JSON"}) + ngx.log(ngx.ERR, "block-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end @@ -286,22 +292,34 @@ function _M.handle_block_ip_request() local ttl = data.ttl or 86400 if not ip then + ngx.log(ngx.ERR, "block-ip: missing IP in request from ", client_ip) json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then + ngx.log(ngx.ERR, "block-ip: shared dict not available") json_response(500, {error = "Shared dict not available"}) return end - dict:set("ip:" .. ip, true, ttl) - json_response(200, {success = true, ip = ip}) + local ok, set_err = dict:set("ip:" .. ip, true, ttl) + if not ok then + ngx.log(ngx.ERR, "block-ip: failed to set IP ", ip, " in dict: ", set_err) + json_response(500, {error = "Failed to block IP: " .. (set_err or "unknown")}) + return + end + + ngx.log(ngx.INFO, "block-ip: blocked ", ip, " for ", ttl, "s") + json_response(200, {success = true, ip = ip, ttl = ttl}) end function _M.handle_unblock_ip_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "unblock-ip: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end @@ -309,38 +327,47 @@ function _M.handle_unblock_ip_request() ngx.req.read_body() local body = ngx.req.get_body_data() if not body then + ngx.log(ngx.ERR, "unblock-ip: no body from ", client_ip) json_response(400, {error = "No body provided"}) return end local data, err = cjson.decode(body) if not data then - json_response(400, {error = "Invalid JSON"}) + ngx.log(ngx.ERR, "unblock-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) return end local ip = data.ip if not ip then + ngx.log(ngx.ERR, "unblock-ip: missing IP in request from ", client_ip) json_response(400, {error = "IP address required"}) return end local dict = ngx.shared.blocked_ips if not dict then + ngx.log(ngx.ERR, "unblock-ip: shared dict not available") json_response(500, {error = "Shared dict not available"}) return end dict:delete("ip:" .. ip) + ngx.log(ngx.INFO, "unblock-ip: unblocked ", ip) json_response(200, {success = true, ip = ip}) end function _M.handle_refresh_request() + local client_ip = ngx.var.remote_addr + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "refresh: method not allowed from ", client_ip) json_response(405, {error = "Method not allowed"}) return end + ngx.log(ngx.INFO, "refresh: refreshing blocked IPs cache") _M.refresh_blocked_ips() json_response(200, {success = true, message = "Cache refreshed"}) end