package handlers import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" ) // setupAuditTestDB creates an in-memory SQLite database for security audit tests func setupAuditTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) require.NoError(t, err) require.NoError(t, db.AutoMigrate( &models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.Setting{}, )) return db } // ============================================================================= // SECURITY AUDIT: SQL Injection Tests // ============================================================================= func TestSecurityHandler_GetStatus_SQLInjection(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Seed malicious setting keys that could be used in SQL injection maliciousKeys := []string{ "security.cerberus.enabled'; DROP TABLE settings;--", "security.cerberus.enabled\"; DROP TABLE settings;--", "security.cerberus.enabled OR 1=1--", "security.cerberus.enabled UNION SELECT * FROM users--", } for _, key := range maliciousKeys { // Attempt to seed with malicious key (should fail or be harmless) setting := models.Setting{Key: key, Value: "true"} db.Create(&setting) } cfg := config.SecurityConfig{CerberusEnabled: false} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should return 200 and valid JSON despite malicious data assert.Equal(t, http.StatusOK, w.Code) var resp map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(t, err) assert.Contains(t, resp, "cerberus") } func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/decisions", h.CreateDecision) // Attempt SQL injection via payload fields maliciousPayloads := []map[string]string{ {"ip": "'; DROP TABLE security_decisions;--", "action": "block"}, {"ip": "127.0.0.1", "action": "'; DELETE FROM security_decisions;--"}, {"ip": "\" OR 1=1; --", "action": "allow"}, {"ip": "127.0.0.1", "action": "block", "details": "'; DROP TABLE users;--"}, } for i, payload := range maliciousPayloads { t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) { body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/api/v1/security/decisions", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should return 200 (created) or 400 (bad request) but NOT crash assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusBadRequest, "Expected 200 or 400, got %d", w.Code) // Verify tables still exist var count int64 db.Raw("SELECT COUNT(*) FROM security_decisions").Scan(&count) // Should not error from SQL injection assert.GreaterOrEqual(t, count, int64(0)) }) } } // ============================================================================= // SECURITY AUDIT: Input Validation Tests // ============================================================================= func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) // Try to submit a 3MB payload (should be rejected by service) hugeContent := strings.Repeat("SecRule REQUEST_URI \"@contains /admin\" \"id:1000,phase:1,deny\"\n", 50000) payload := map[string]any{ "name": "huge-ruleset", "content": hugeContent, } body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should be rejected (either 400 or 500 indicating content too large) // The service limits to 2MB if len(hugeContent) > 2*1024*1024 { assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusInternalServerError, "Expected rejection of huge payload, got %d", w.Code) } } func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) payload := map[string]any{ "name": "", "content": "SecRule REQUEST_URI \"@contains /admin\"", } body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) var resp map[string]any _ = json.Unmarshal(w.Body.Bytes(), &resp) assert.Contains(t, resp, "error") } func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/decisions", h.CreateDecision) testCases := []struct { name string payload map[string]string wantCode int }{ {"empty_ip", map[string]string{"ip": "", "action": "block"}, http.StatusBadRequest}, {"empty_action", map[string]string{"ip": "127.0.0.1", "action": ""}, http.StatusBadRequest}, {"both_empty", map[string]string{"ip": "", "action": ""}, http.StatusBadRequest}, {"valid", map[string]string{"ip": "127.0.0.1", "action": "block"}, http.StatusOK}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { body, _ := json.Marshal(tc.payload) req := httptest.NewRequest("POST", "/api/v1/security/decisions", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tc.wantCode, w.Code) }) } } // ============================================================================= // SECURITY AUDIT: Settings Toggle Persistence Tests // ============================================================================= func TestSecurityHandler_GetStatus_SettingsOverride(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Create SecurityConfig with all security features enabled (DB priority) secCfg := &models.SecurityConfig{ Name: "default", // Required - GetStatus looks for name='default' Enabled: true, WAFMode: "block", // "block" mode enables WAF RateLimitMode: "enabled", CrowdSecMode: "local", // "local" mode enables CrowdSec RateLimitEnable: true, } require.NoError(t, db.Create(secCfg).Error) // Seed settings (these won't override DB SecurityConfig for WAF/Rate Limit/CrowdSec) settings := []models.Setting{ {Key: "feature.cerberus.enabled", Value: "true", Category: "feature"}, {Key: "security.waf.enabled", Value: "true", Category: "security"}, {Key: "security.rate_limit.enabled", Value: "true", Category: "security"}, {Key: "security.crowdsec.enabled", Value: "true", Category: "security"}, } for _, s := range settings { require.NoError(t, db.Create(&s).Error) } // Static config has everything disabled (lowest priority) cfg := config.SecurityConfig{ CerberusEnabled: false, WAFMode: "disabled", RateLimitMode: "disabled", CrowdSecMode: "disabled", ACLMode: "enabled", // ACL comes from static config only } h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) // Verify DB config is used (highest priority) for SecurityConfig features assert.True(t, resp["cerberus"]["enabled"].(bool), "cerberus should be enabled via DB config") assert.True(t, resp["waf"]["enabled"].(bool), "waf should be enabled via DB config") assert.True(t, resp["rate_limit"]["enabled"].(bool), "rate_limit should be enabled via DB config") assert.True(t, resp["crowdsec"]["enabled"].(bool), "crowdsec should be enabled via DB config") // ACL comes from static config only (not in SecurityConfig model) assert.True(t, resp["acl"]["enabled"].(bool), "acl should be enabled via static config") } func TestSecurityHandler_GetStatus_DisabledViaSettings(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Seed settings that disable everything settings := []models.Setting{ {Key: "feature.cerberus.enabled", Value: "false", Category: "feature"}, {Key: "security.waf.enabled", Value: "false", Category: "security"}, {Key: "security.rate_limit.enabled", Value: "false", Category: "security"}, {Key: "security.crowdsec.enabled", Value: "false", Category: "security"}, } for _, s := range settings { require.NoError(t, db.Create(&s).Error) } // Config has everything enabled cfg := config.SecurityConfig{ CerberusEnabled: true, WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local", } h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) // Verify settings override config to disabled assert.False(t, resp["cerberus"]["enabled"].(bool), "cerberus should be disabled via settings") assert.False(t, resp["waf"]["enabled"].(bool), "waf should be disabled via settings") assert.False(t, resp["rate_limit"]["enabled"].(bool), "rate_limit should be disabled via settings") assert.False(t, resp["crowdsec"]["enabled"].(bool), "crowdsec should be disabled via settings") } // ============================================================================= // SECURITY AUDIT: Delete RuleSet Validation // ============================================================================= func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet) testCases := []struct { name string id string wantCode int }{ {"empty_id", "", http.StatusNotFound}, // gin routes to 404 for missing param {"non_numeric", "abc", http.StatusBadRequest}, {"negative", "-1", http.StatusBadRequest}, {"sql_injection", "1%3B+DROP+TABLE+security_rule_sets", http.StatusBadRequest}, {"not_found", "999999", http.StatusNotFound}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { url := "/api/v1/security/rulesets/" + tc.id if tc.id == "" { url = "/api/v1/security/rulesets/" } req := httptest.NewRequest("DELETE", url, http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tc.wantCode, w.Code, "ID: %s", tc.id) }) } } // ============================================================================= // SECURITY AUDIT: XSS Prevention (stored XSS in ruleset content) // ============================================================================= func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/rulesets", h.UpsertRuleSet) router.GET("/api/v1/security/rulesets", h.ListRuleSets) // Store content with XSS payload xssPayload := `` payload := map[string]any{ "name": "xss-test", "content": xssPayload, } body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) // Accept that content is stored (backend stores as-is, frontend must sanitize) assert.Equal(t, http.StatusOK, w.Code) // Verify it's stored and returned as JSON (not rendered as HTML) req2 := httptest.NewRequest("GET", "/api/v1/security/rulesets", http.NoBody) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) // Content-Type should be application/json contentType := w2.Header().Get("Content-Type") assert.Contains(t, contentType, "application/json") // The XSS payload should be JSON-escaped, not executable assert.Contains(t, w2.Body.String(), `\u003cscript\u003e`) } // ============================================================================= // SECURITY AUDIT: Rate Limiting Config Bounds // ============================================================================= func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.PUT("/api/v1/security/config", h.UpdateConfig) testCases := []struct { name string payload map[string]any wantOK bool }{ { "valid_limits", map[string]any{"rate_limit_requests": 100, "rate_limit_burst": 10, "rate_limit_window_sec": 60}, true, }, { "zero_requests", map[string]any{"rate_limit_requests": 0, "rate_limit_burst": 10}, true, // Backend accepts, frontend validates }, { "negative_burst", map[string]any{"rate_limit_requests": 100, "rate_limit_burst": -1}, true, // Backend accepts, frontend validates }, { "huge_values", map[string]any{"rate_limit_requests": 999999999, "rate_limit_burst": 999999999}, true, // Backend accepts (no upper bound validation currently) }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { body, _ := json.Marshal(tc.payload) req := httptest.NewRequest("PUT", "/api/v1/security/config", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if tc.wantOK { assert.Equal(t, http.StatusOK, w.Code) } else { assert.NotEqual(t, http.StatusOK, w.Code) } }) } } // ============================================================================= // SECURITY AUDIT: DB Nil Handling // ============================================================================= func TestSecurityHandler_GetStatus_NilDB(t *testing.T) { gin.SetMode(gin.TestMode) // Handler with nil DB should not panic cfg := config.SecurityConfig{CerberusEnabled: true} h := NewSecurityHandler(cfg, nil, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() // Should not panic assert.NotPanics(t, func() { router.ServeHTTP(w, req) }) assert.Equal(t, http.StatusOK, w.Code) } // ============================================================================= // SECURITY AUDIT: Break-Glass Token Security // ============================================================================= func TestSecurityHandler_Enable_WithoutWhitelist(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Create config without whitelist existingCfg := models.SecurityConfig{Name: "default", AdminWhitelist: ""} require.NoError(t, db.Create(&existingCfg).Error) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/enable", h.Enable) // Try to enable without token or whitelist req := httptest.NewRequest("POST", "/api/v1/security/enable", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should be rejected assert.Equal(t, http.StatusBadRequest, w.Code) var resp map[string]string _ = json.Unmarshal(w.Body.Bytes(), &resp) assert.Contains(t, resp["error"], "whitelist") } func TestSecurityHandler_Disable_RequiresToken(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Create config with break-glass hash existingCfg := models.SecurityConfig{Name: "default", Enabled: true} require.NoError(t, db.Create(&existingCfg).Error) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.POST("/api/v1/security/disable", h.Disable) // Try to disable from non-localhost without token req := httptest.NewRequest("POST", "/api/v1/security/disable", http.NoBody) req.RemoteAddr = "10.0.0.5:12345" w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should be rejected assert.Equal(t, http.StatusUnauthorized, w.Code) } // ============================================================================= // SECURITY AUDIT: CrowdSec Mode Validation // ============================================================================= func TestSecurityHandler_GetStatus_CrowdSecModeValidation(t *testing.T) { gin.SetMode(gin.TestMode) db := setupAuditTestDB(t) // Try to set invalid CrowdSec modes via settings invalidModes := []string{"remote", "external", "cloud", "api", "../../../etc/passwd"} for _, mode := range invalidModes { t.Run("mode_"+mode, func(t *testing.T) { // Clear settings db.Exec("DELETE FROM settings") // Set invalid mode setting := models.Setting{Key: "security.crowdsec.mode", Value: mode, Category: "security"} db.Create(&setting) cfg := config.SecurityConfig{} h := NewSecurityHandler(cfg, db, nil) router := gin.New() router.GET("/api/v1/security/status", h.GetStatus) req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]map[string]any err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") // Invalid modes should be normalized to "disabled" assert.Equal(t, "disabled", resp["crowdsec"]["mode"], "Invalid mode '%s' should be normalized to 'disabled'", mode) }) } }