// Package cerberus provides lightweight security checks (WAF, ACL, CrowdSec) with notification support. package cerberus import ( "context" "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/metrics" "github.com/Wikid82/charon/backend/internal/models" securitypkg "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" ) // Cerberus provides a lightweight facade for security checks (WAF, CrowdSec, ACL). type Cerberus struct { cfg config.SecurityConfig db *gorm.DB accessSvc *services.AccessListService securityNotifySvc *services.SecurityNotificationService enhancedNotifySvc *services.EnhancedSecurityNotificationService // Settings cache for performance - avoids DB queries on every request settingsCache map[string]string settingsCacheMu sync.RWMutex settingsCacheTime time.Time settingsCacheTTL time.Duration } // New creates a new Cerberus instance func New(cfg config.SecurityConfig, db *gorm.DB) *Cerberus { return &Cerberus{ cfg: cfg, db: db, accessSvc: services.NewAccessListService(db), securityNotifySvc: services.NewSecurityNotificationService(db), enhancedNotifySvc: services.NewEnhancedSecurityNotificationService(db), settingsCache: make(map[string]string), settingsCacheTTL: 60 * time.Second, } } // getSetting retrieves a setting with in-memory caching. // Returns the value and a boolean indicating if the key was found. func (c *Cerberus) getSetting(key string) (string, bool) { if c.db == nil { return "", false } // Fast path: check cache with read lock c.settingsCacheMu.RLock() if time.Since(c.settingsCacheTime) < c.settingsCacheTTL { val, ok := c.settingsCache[key] c.settingsCacheMu.RUnlock() return val, ok } c.settingsCacheMu.RUnlock() // Slow path: refresh cache with write lock c.settingsCacheMu.Lock() defer c.settingsCacheMu.Unlock() // Double-check: another goroutine might have refreshed cache if time.Since(c.settingsCacheTime) < c.settingsCacheTTL { val, ok := c.settingsCache[key] return val, ok } // Refresh entire cache from DB (batch query is faster than individual queries) var settings []models.Setting if err := c.db.Where("key LIKE ?", "security.%").Find(&settings).Error; err != nil { logger.Log().WithError(err).Debug("Failed to refresh settings cache") return "", false } // Update cache c.settingsCache = make(map[string]string) for _, s := range settings { c.settingsCache[s.Key] = s.Value } c.settingsCacheTime = time.Now() val, ok := c.settingsCache[key] return val, ok } // InvalidateCache forces cache refresh on next access. // Call this after updating security settings. func (c *Cerberus) InvalidateCache() { c.settingsCacheMu.Lock() c.settingsCacheTime = time.Time{} // Zero time forces refresh c.settingsCacheMu.Unlock() } // IsEnabled returns whether Cerberus features are enabled via config or settings. func (c *Cerberus) IsEnabled() bool { // DB-backed break-glass disable must take effect even when static config defaults to enabled. // This keeps the API reachable and prevents accidental lockouts when Cerberus/ACL is disabled via /security/disable. if c.db != nil { var sc models.SecurityConfig if err := c.db.Where("name = ?", "default").First(&sc).Error; err == nil { if !sc.Enabled { return false } } var s models.Setting // Runtime feature flag (highest priority after break-glass disable) if err := c.db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil { return strings.EqualFold(s.Value, "true") } // Fallback to legacy setting for backward compatibility s = models.Setting{} // Reset to prevent ID leakage from previous query if err := c.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil { return strings.EqualFold(s.Value, "true") } } if c.cfg.CerberusEnabled { return true } // If any of the security modes are explicitly enabled, consider Cerberus enabled. // Treat empty values as disabled to avoid treating zero-values ("") as enabled. if c.cfg.CrowdSecMode == "local" { return true } if (c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled") || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" { return true } // Back-compat: check if all config fields are their zero values (implies defaults = enabled) // Note: cannot use == for struct comparison when it contains slices if c.cfg.CrowdSecMode == "" && c.cfg.CrowdSecAPIURL == "" && c.cfg.CrowdSecAPIKey == "" && c.cfg.CrowdSecConfigDir == "" && c.cfg.WAFMode == "" && c.cfg.RateLimitMode == "" && c.cfg.ACLMode == "" && !c.cfg.CerberusEnabled && len(c.cfg.ManagementCIDRs) == 0 { return true } return false } // Middleware returns a Gin middleware that enforces Cerberus checks when enabled. func (c *Cerberus) Middleware() gin.HandlerFunc { return func(ctx *gin.Context) { // Check for emergency bypass flag (set by EmergencyBypass middleware) if bypass, exists := ctx.Get("emergency_bypass"); exists && bypass.(bool) { logger.Log().WithField("path", util.SanitizeForLog(ctx.Request.URL.Path)).Debug("Cerberus: Skipping security checks (emergency bypass)") ctx.Next() return } if !c.IsEnabled() { ctx.Next() return } // WAF: The actual WAF protection is handled by the Coraza plugin at the Caddy layer. // This middleware just tracks metrics for requests when WAF is enabled. // Check runtime setting first (from cache), then fall back to static config. wafEnabled := c.cfg.WAFMode != "" && c.cfg.WAFMode != "disabled" if val, ok := c.getSetting("security.waf.enabled"); ok { wafEnabled = strings.EqualFold(val, "true") } if wafEnabled { metrics.IncWAFRequest() // Note: Actual blocking is done by Coraza in Caddy. This middleware // provides defense-in-depth tracking and ACL enforcement only. } // Rate Limit: Actual rate limiting is done by Caddy middleware. // Notifications are sent when Caddy middleware detects limit exceeded via webhook. // No per-request tracking needed here (Blocker 1: Production runtime dispatch for rate limit hits). // ACL: simple per-request evaluation against all access lists if enabled // Check runtime setting first (from cache), then fall back to static config. aclEnabled := c.cfg.ACLMode == "enabled" if val, ok := c.getSetting("security.acl.enabled"); ok { aclEnabled = strings.EqualFold(val, "true") } if aclEnabled { clientIP := util.CanonicalizeIPForSecurity(ctx.ClientIP()) isAdmin := c.isAuthenticatedAdmin(ctx) adminWhitelistConfigured := false if isAdmin { whitelisted, hasWhitelist := c.adminWhitelistStatus(clientIP) adminWhitelistConfigured = hasWhitelist if whitelisted { ctx.Next() return } } acls, err := c.accessSvc.List() if err == nil { activeCount := 0 for _, acl := range acls { if !acl.Enabled { continue } activeCount++ allowed, _, err := c.accessSvc.TestIP(acl.ID, clientIP) if err == nil && !allowed { // Send security notification via appropriate dispatch path _ = c.sendSecurityNotification(context.Background(), models.SecurityEvent{ EventType: "acl_deny", Severity: "warn", Message: "Access control list blocked request", ClientIP: clientIP, Path: ctx.Request.URL.Path, Timestamp: time.Now(), Metadata: map[string]any{ "acl_name": acl.Name, "acl_id": acl.ID, }, }) ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Blocked by access control list"}) return } } if activeCount == 0 { if isAdmin && !adminWhitelistConfigured { ctx.Next() return } ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Blocked by access control list"}) return } } } // CrowdSec integration: The actual IP blocking is handled by the caddy-crowdsec-bouncer // plugin at the Caddy layer. This middleware provides defense-in-depth tracking. // When CrowdSec mode is "local", the bouncer communicates directly with the LAPI // to receive ban decisions and block malicious IPs before they reach the application. if c.cfg.CrowdSecMode == "local" { // Track that this request passed through CrowdSec evaluation // Note: Blocking decisions are made by Caddy bouncer, not here metrics.IncCrowdSecRequest() logger.Log().WithField("client_ip", util.SanitizeForLog(ctx.ClientIP())).WithField("path", util.SanitizeForLog(ctx.Request.URL.Path)).Debug("Request evaluated by CrowdSec bouncer at Caddy layer") // Blocker 1: Production runtime dispatch for CrowdSec decisions // CrowdSec decisions trigger notifications when bouncer blocks at Caddy layer // The actual notification is sent by Caddy via webhook callback to /api/v1/security/events } ctx.Next() } } // NotifySecurityEvent provides external notification hook for security events from Caddy/Coraza. // Blocker 1: Production runtime dispatch path for WAF blocks, Rate limit hits, and CrowdSec decisions. func (c *Cerberus) NotifySecurityEvent(ctx *gin.Context, event models.SecurityEvent) error { if !c.IsEnabled() { return nil } return c.sendSecurityNotification(ctx.Request.Context(), event) } func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool { role, exists := ctx.Get("role") if !exists { return false } roleStr, ok := role.(string) if !ok || roleStr != string(models.RoleAdmin) { return false } userID, exists := ctx.Get("userID") if !exists { return false } switch id := userID.(type) { case uint: return id > 0 case int: return id > 0 case int64: return id > 0 default: return false } } func (c *Cerberus) adminWhitelistStatus(clientIP string) (bool, bool) { if c.db == nil { return false, false } var sc models.SecurityConfig if err := c.db.Where("name = ?", "default").First(&sc).Error; err != nil { return false, false } if strings.TrimSpace(sc.AdminWhitelist) == "" { return false, false } return securitypkg.IsIPInCIDRList(clientIP, sc.AdminWhitelist), true } // sendSecurityNotification dispatches a security event notification. // Blocker 1: Wires runtime dispatch to provider-event authority under feature flag semantics. // Blocker 2: Enforces notify-only fail-closed behavior - no legacy fallback when flag absent/false. func (c *Cerberus) sendSecurityNotification(ctx context.Context, event models.SecurityEvent) error { if c.db == nil { return nil } // Check feature flag var setting models.Setting err := c.db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error // If feature flag is enabled, use provider-based dispatch if err == nil && strings.EqualFold(setting.Value, "true") { return c.enhancedNotifySvc.SendViaProviders(ctx, event) } // Blocker 2: Feature flag disabled or not found - fail closed (no notification, no legacy fallback) logger.Log().WithField("event_type", event.EventType).Debug("Security notification suppressed: feature flag disabled or absent") return nil }