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
2 changes: 1 addition & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 2 additions & 28 deletions internal/infra/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 1 addition & 11 deletions internal/infra/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
49 changes: 2 additions & 47 deletions internal/security/nginx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 5 additions & 22 deletions internal/security/nginx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions templates/infra/nginx/lua/security.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions templates/infra/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
17 changes: 13 additions & 4 deletions templates/infra/nginx/nginx.lua.conf
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,26 @@ 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 {
security = require "security"
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()
Expand All @@ -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
Expand Down
5 changes: 0 additions & 5 deletions test/e2e/lua_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading