Files
Charon/backend/internal/api/handlers/security_handler_audit_test.go
GitHub Actions 9ad3afbd22 Fix Rate Limiting Issues
- Updated Definition of Done report with detailed checks and results for backend and frontend tests.
- Documented issues related to race conditions and test failures in QA reports.
- Improved security scan notes and code cleanup status in QA reports.
- Added summaries for rate limit integration test fixes, including root causes and resolutions.
- Introduced new debug and integration scripts for rate limit testing.
- Updated security documentation to reflect changes in configuration and troubleshooting steps.
- Enhanced troubleshooting guides for CrowdSec and Go language server (gopls) errors.
- Improved frontend and scripts README files for clarity and usage instructions.
2025-12-12 19:21:44 +00:00

589 lines
19 KiB
Go

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]interface{}
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]interface{}{
"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]interface{}{
"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]interface{}
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]interface{}
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]interface{}
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 := `<script>alert('XSS')</script>`
payload := map[string]interface{}{
"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]interface{}
wantOK bool
}{
{
"valid_limits",
map[string]interface{}{"rate_limit_requests": 100, "rate_limit_burst": 10, "rate_limit_window_sec": 60},
true,
},
{
"zero_requests",
map[string]interface{}{"rate_limit_requests": 0, "rate_limit_burst": 10},
true, // Backend accepts, frontend validates
},
{
"negative_burst",
map[string]interface{}{"rate_limit_requests": 100, "rate_limit_burst": -1},
true, // Backend accepts, frontend validates
},
{
"huge_values",
map[string]interface{}{"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]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Invalid modes should be normalized to "disabled"
assert.Equal(t, "disabled", resp["crowdsec"]["mode"],
"Invalid mode '%s' should be normalized to 'disabled'", mode)
})
}
}