From 6bc61ce9839745081425315666654b280b1001a4 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 22 Dec 2025 20:56:40 +0100 Subject: [PATCH] feat(security): Implement Lua-based IP blocking Signed-off-by: nfebe --- config.example.yml | 2 +- internal/infra/manager.go | 30 +------- internal/infra/manager_test.go | 12 +-- internal/security/nginx.go | 49 +----------- internal/security/nginx_test.go | 27 ++----- templates/infra/nginx/lua/security.lua | 100 +++++++++++++++++++++++++ templates/infra/nginx/nginx.conf | 3 - templates/infra/nginx/nginx.lua.conf | 17 ++++- test/e2e/lua_test.go | 5 -- test/e2e/nginx/lua/nginx.conf | 17 ++++- test/e2e/nginx/security/nginx.conf | 3 - test/e2e/security_test.go | 15 +--- 12 files changed, 138 insertions(+), 142 deletions(-) diff --git a/config.example.yml b/config.example.yml index b2b9a44..c86cd98 100644 --- a/config.example.yml +++ b/config.example.yml @@ -64,6 +64,6 @@ security: scan_interval: 30s retention_days: 30 rate_threshold: 100 - auto_block_enabled: false + auto_block_enabled: true auto_block_threshold: 50 auto_block_duration: 24h0m0s diff --git a/internal/infra/manager.go b/internal/infra/manager.go index f9936d7..15f5024 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -400,19 +400,11 @@ func (m *Manager) SetNginxRealtimeCaptureWithStatus(enabled bool) (map[string]in } } - // Ensure conf.d directory and security config files exist + // Ensure conf.d directory and rate limits config exists confDir := filepath.Join(nginxDir, "conf.d") if err := os.MkdirAll(confDir, 0755); err != nil { errors = append(errors, fmt.Sprintf("failed to create conf.d directory: %v", err)) } else { - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - content := "# Auto-generated by FlatRun Security\n# No blocked IPs\n" - if err := os.WriteFile(blockedIPsPath, []byte(content), 0644); err != nil { - errors = append(errors, fmt.Sprintf("failed to create blocked_ips.conf: %v", err)) - } - } - rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { content := "# Auto-generated by FlatRun Security\n# No rate limit zones defined\n" @@ -703,16 +695,7 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { result.Recommendations = append(result.Recommendations, "Enable realtime capture in Security settings") } - // Check 3: blocked_ips.conf exists - blockedIPsPath := filepath.Join(nginxDir, "conf.d", "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); err == nil { - result.Checks["blocked_ips_conf_exists"] = true - } else { - result.Checks["blocked_ips_conf_exists"] = false - result.Issues = append(result.Issues, "blocked_ips.conf does not exist") - } - - // Check 4: rate_limits.conf exists + // Check 3: rate_limits.conf exists rateLimitsPath := filepath.Join(nginxDir, "conf.d", "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); err == nil { result.Checks["rate_limits_conf_exists"] = true @@ -1216,15 +1199,6 @@ func (m *Manager) RefreshSecurityScripts() (*RefreshSecurityScriptsResult, error } } - // Ensure blocked_ips.conf exists - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - content := "# Auto-generated - No blocked IPs\n" - if err := os.WriteFile(blockedIPsPath, []byte(content), 0644); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to create blocked_ips.conf: %v", err)) - } - } - // Ensure rate_limits.conf exists rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { diff --git a/internal/infra/manager_test.go b/internal/infra/manager_test.go index 772b9b8..5ce4208 100644 --- a/internal/infra/manager_test.go +++ b/internal/infra/manager_test.go @@ -69,11 +69,6 @@ func TestSetNginxRealtimeCapture(t *testing.T) { t.Error("security.lua should be created when realtime capture is enabled") } - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - t.Error("blocked_ips.conf should be created") - } - rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { t.Error("rate_limits.conf should be created") @@ -98,12 +93,7 @@ func TestSetNginxRealtimeCapture(t *testing.T) { t.Error("lua directory should be removed when realtime capture is disabled") } - // conf.d files should still exist (they may be used for blocking IPs etc) - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - t.Error("blocked_ips.conf should still exist after disabling realtime capture") - } - + // conf.d files should still exist (they may be used for rate limiting etc) rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { t.Error("rate_limits.conf should still exist after disabling realtime capture") diff --git a/internal/security/nginx.go b/internal/security/nginx.go index 09866d9..54cbe9e 100644 --- a/internal/security/nginx.go +++ b/internal/security/nginx.go @@ -24,23 +24,15 @@ func NewNginxConfigGenerator(manager *Manager, configPath string) *NginxConfigGe } } -// EnsureSecurityConfigFiles creates the blocked_ips.conf and rate_limits.conf files -// if they don't exist. This is called during initialization to ensure nginx can start. +// EnsureSecurityConfigFiles creates the rate_limits.conf file +// if it doesn't exist. This is called during initialization to ensure nginx can start. func (g *NginxConfigGenerator) EnsureSecurityConfigFiles() error { - blockedIPsPath := filepath.Join(g.configPath, "blocked_ips.conf") rateLimitsPath := filepath.Join(g.configPath, "rate_limits.conf") if err := os.MkdirAll(g.configPath, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - content := "# Auto-generated by FlatRun Security\n# No blocked IPs\n" - if err := os.WriteFile(blockedIPsPath, []byte(content), 0644); err != nil { - return fmt.Errorf("failed to create blocked_ips.conf: %w", err) - } - } - if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { content := "# Auto-generated by FlatRun Security\n# No rate limit zones defined\n" if err := os.WriteFile(rateLimitsPath, []byte(content), 0644); err != nil { @@ -51,32 +43,6 @@ func (g *NginxConfigGenerator) EnsureSecurityConfigFiles() error { return nil } -// GenerateBlockedIPsConfig generates the blocked_ips.conf file -func (g *NginxConfigGenerator) GenerateBlockedIPsConfig() (string, error) { - blockedIPs, err := g.manager.GetActiveBlockedIPs() - if err != nil { - return "", err - } - - var buf bytes.Buffer - buf.WriteString("# Auto-generated by FlatRun Security\n") - buf.WriteString("# Do not edit manually - changes will be overwritten\n\n") - - if len(blockedIPs) == 0 { - buf.WriteString("# No blocked IPs\n") - } else { - for _, ip := range blockedIPs { - comment := "" - if ip.Reason != "" { - comment = fmt.Sprintf(" # %s", ip.Reason) - } - buf.WriteString(fmt.Sprintf("deny %s;%s\n", ip.IP, comment)) - } - } - - return buf.String(), nil -} - // GenerateRateLimitsConfig generates the rate_limits.conf file func (g *NginxConfigGenerator) GenerateRateLimitsConfig() (string, error) { routes, err := g.manager.GetEnabledProtectedRoutes() @@ -159,17 +125,6 @@ func (g *NginxConfigGenerator) GenerateProtectedPathsConfig(paths []string) stri return buf.String() } -// WriteBlockedIPsConfig writes the blocked_ips.conf file -func (g *NginxConfigGenerator) WriteBlockedIPsConfig() error { - content, err := g.GenerateBlockedIPsConfig() - if err != nil { - return err - } - - filePath := filepath.Join(g.configPath, "blocked_ips.conf") - return writeIfChanged(filePath, content) -} - // WriteRateLimitsConfig writes the rate_limits.conf file func (g *NginxConfigGenerator) WriteRateLimitsConfig() error { content, err := g.GenerateRateLimitsConfig() diff --git a/internal/security/nginx_test.go b/internal/security/nginx_test.go index bb3a4eb..a43c2ff 100644 --- a/internal/security/nginx_test.go +++ b/internal/security/nginx_test.go @@ -26,29 +26,12 @@ func TestEnsureSecurityConfigFiles(t *testing.T) { t.Fatalf("EnsureSecurityConfigFiles failed: %v", err) } - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - t.Error("blocked_ips.conf should be created") - } - rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { t.Error("rate_limits.conf should be created") } }) - t.Run("blocked_ips.conf has valid nginx content", func(t *testing.T) { - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - content, err := os.ReadFile(blockedIPsPath) - if err != nil { - t.Fatalf("failed to read blocked_ips.conf: %v", err) - } - - if !strings.Contains(string(content), "# Auto-generated") { - t.Error("blocked_ips.conf should contain auto-generated comment") - } - }) - t.Run("rate_limits.conf has valid nginx content", func(t *testing.T) { rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") content, err := os.ReadFile(rateLimitsPath) @@ -62,9 +45,9 @@ func TestEnsureSecurityConfigFiles(t *testing.T) { }) t.Run("does not overwrite existing files", func(t *testing.T) { - blockedIPsPath := filepath.Join(confDir, "blocked_ips.conf") - customContent := "# Custom blocked IPs\ndeny 1.2.3.4;\n" - if err := os.WriteFile(blockedIPsPath, []byte(customContent), 0644); err != nil { + rateLimitsPath := filepath.Join(confDir, "rate_limits.conf") + customContent := "# Custom rate limits\nlimit_req_zone $binary_remote_addr zone=test:10m rate=1r/s;\n" + if err := os.WriteFile(rateLimitsPath, []byte(customContent), 0644); err != nil { t.Fatalf("failed to write custom content: %v", err) } @@ -73,9 +56,9 @@ func TestEnsureSecurityConfigFiles(t *testing.T) { t.Fatalf("EnsureSecurityConfigFiles failed: %v", err) } - content, err := os.ReadFile(blockedIPsPath) + content, err := os.ReadFile(rateLimitsPath) if err != nil { - t.Fatalf("failed to read blocked_ips.conf: %v", err) + t.Fatalf("failed to read rate_limits.conf: %v", err) } if string(content) != customContent { diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index 9ce40bd..e9116f4 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -10,6 +10,11 @@ local _M = {} local AGENT_IP = "{{.AgentIP}}" local AGENT_PORT = {{.AgentPort}} +-- Blocked IPs cache settings +local BLOCKED_IPS_CACHE_TTL = 30 -- seconds +local BLOCKED_IPS_CACHE_KEY = "blocked_ips_list" +local BLOCKED_IPS_LAST_FETCH = "blocked_ips_last_fetch" + -- Suspicious paths patterns local suspicious_patterns = { "%.env", @@ -57,6 +62,101 @@ local scanner_patterns = { "zgrab", } +-- 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 + + -- Check if this specific IP is marked as blocked + local is_blocked = dict:get("ip:" .. ip) + if is_blocked ~= nil then + return is_blocked + end + + -- Check if we need to refresh the cache + local last_fetch = dict:get(BLOCKED_IPS_LAST_FETCH) or 0 + local now = ngx.time() + + if now - last_fetch > BLOCKED_IPS_CACHE_TTL then + -- Refresh in background to not block the request + 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 conn_ok, conn_err = httpc:connect({ + host = AGENT_IP, + port = AGENT_PORT, + scheme = "http", + }) + + if not conn_ok then + ngx.log(ngx.ERR, "Failed to connect to agent for blocked IPs: ", conn_err) + return + end + + local res, req_err = httpc:request({ + method = "GET", + path = "/api/security/blocked-ips", + headers = { + ["Host"] = AGENT_IP .. ":" .. AGENT_PORT, + } + }) + + if not res then + ngx.log(ngx.ERR, "Failed to fetch blocked IPs: ", req_err) + httpc:close() + return + end + + local body = res:read_body() + httpc:close() + + if res.status ~= 200 then + ngx.log(ngx.ERR, "Blocked IPs API returned status: ", res.status) + return + end + + local data, decode_err = cjson.decode(body) + if not data then + ngx.log(ngx.ERR, "Failed to decode blocked IPs response: ", decode_err) + return + end + + -- Clear old entries and set new ones + 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 + function _M.is_suspicious_path(uri) if not uri then return false end local uri_lower = string.lower(uri) diff --git a/templates/infra/nginx/nginx.conf b/templates/infra/nginx/nginx.conf index 37c7c0f..51b3a9a 100644 --- a/templates/infra/nginx/nginx.conf +++ b/templates/infra/nginx/nginx.conf @@ -38,9 +38,6 @@ http { # Docker DNS resolver resolver 127.0.0.11 valid=30s; - # Include blocked IPs - include /etc/nginx/conf.d/blocked_ips.conf; - # Include rate limit zones include /etc/nginx/conf.d/rate_limits.conf; diff --git a/templates/infra/nginx/nginx.lua.conf b/templates/infra/nginx/nginx.lua.conf index af443d6..ddd8892 100644 --- a/templates/infra/nginx/nginx.lua.conf +++ b/templates/infra/nginx/nginx.lua.conf @@ -41,6 +41,7 @@ http { # Shared dictionary for security events lua_shared_dict security_events 10m; lua_shared_dict ip_rate_limit 10m; + lua_shared_dict blocked_ips 5m; # Load Lua modules init_by_lua_block { @@ -48,6 +49,18 @@ http { traffic = require "traffic" } + # Initialize blocked IPs cache on worker start + init_worker_by_lua_block { + security.init_blocked_ips() + } + + # Check blocked IPs on every request + access_by_lua_block { + if security.is_blocked(ngx.var.remote_addr) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + } + # Global traffic logging - logs ALL requests log_by_lua_block { traffic.log_request() @@ -56,10 +69,6 @@ http { # Docker DNS resolver resolver 127.0.0.11 valid=30s; - # Include blocked IPs (if exists) - include /etc/nginx/conf.d/blocked_ips.conf; - - # Include rate limit zones (if exists) include /etc/nginx/conf.d/rate_limits.conf; # Include virtual hosts diff --git a/test/e2e/lua_test.go b/test/e2e/lua_test.go index 0a4d18e..108d58f 100644 --- a/test/e2e/lua_test.go +++ b/test/e2e/lua_test.go @@ -35,11 +35,6 @@ func TestLuaRealtimeCapture(t *testing.T) { cleanupSecurityState(t) t.Run("security config files created", func(t *testing.T) { - blockedIPsPath := filepath.Join(luaDeploymentsPath, "nginx", "conf.d", "blocked_ips.conf") - if _, err := os.Stat(blockedIPsPath); os.IsNotExist(err) { - t.Fatalf("blocked_ips.conf should exist") - } - rateLimitsPath := filepath.Join(luaDeploymentsPath, "nginx", "conf.d", "rate_limits.conf") if _, err := os.Stat(rateLimitsPath); os.IsNotExist(err) { t.Fatalf("rate_limits.conf should exist") diff --git a/test/e2e/nginx/lua/nginx.conf b/test/e2e/nginx/lua/nginx.conf index 3c3b789..f26f670 100644 --- a/test/e2e/nginx/lua/nginx.conf +++ b/test/e2e/nginx/lua/nginx.conf @@ -25,12 +25,25 @@ http { # Shared dictionary for security events lua_shared_dict security_events 10m; lua_shared_dict ip_rate_limit 10m; + lua_shared_dict blocked_ips 5m; # Load security module init_by_lua_block { security = require "security" } + # Initialize blocked IPs cache on worker start + init_worker_by_lua_block { + security.init_blocked_ips() + } + + # Check blocked IPs on every request + access_by_lua_block { + if security.is_blocked(ngx.var.remote_addr) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + } + # Docker DNS resolver resolver 127.0.0.11 valid=10s ipv6=off; @@ -39,10 +52,6 @@ http { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; - # Include blocked IPs (if exists) - include /tmp/flatrun-e2e-lua/nginx/conf.d/blocked_ips.conf; - - # Include rate limit zones (if exists) include /tmp/flatrun-e2e-lua/nginx/conf.d/rate_limits.conf; # Default configs diff --git a/test/e2e/nginx/security/nginx.conf b/test/e2e/nginx/security/nginx.conf index 1ef4226..7afd8d6 100644 --- a/test/e2e/nginx/security/nginx.conf +++ b/test/e2e/nginx/security/nginx.conf @@ -28,9 +28,6 @@ http { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; - # Include blocked IPs (required for security feature) - include /tmp/flatrun-e2e-security/nginx/conf.d/blocked_ips.conf; - # Include rate limit zones (required for security feature) include /tmp/flatrun-e2e-security/nginx/conf.d/rate_limits.conf; diff --git a/test/e2e/security_test.go b/test/e2e/security_test.go index ea84f24..abc85aa 100644 --- a/test/e2e/security_test.go +++ b/test/e2e/security_test.go @@ -32,20 +32,7 @@ func TestSecurityConfigFilesCreated(t *testing.T) { t.Fatalf("Security agent failed to start: %v", err) } - // Test 1: Verify blocked_ips.conf exists and has valid content - t.Run("blocked_ips.conf exists", func(t *testing.T) { - blockedIPsPath := filepath.Join(securityDeploymentsPath, "nginx", "conf.d", "blocked_ips.conf") - content, err := os.ReadFile(blockedIPsPath) - if err != nil { - t.Fatalf("blocked_ips.conf should exist: %v", err) - } - if len(content) == 0 { - t.Error("blocked_ips.conf should not be empty") - } - t.Logf("blocked_ips.conf content:\n%s", string(content)) - }) - - // Test 2: Verify rate_limits.conf exists and has valid content + // Test 1: Verify rate_limits.conf exists and has valid content t.Run("rate_limits.conf exists", func(t *testing.T) { rateLimitsPath := filepath.Join(securityDeploymentsPath, "nginx", "conf.d", "rate_limits.conf") content, err := os.ReadFile(rateLimitsPath)