Files
Charon/backend/internal/api/handlers/security_handler.go

1388 lines
41 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/config"
"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"
)
// WAFExclusionRequest represents a rule exclusion for false positives
type WAFExclusionRequest struct {
RuleID int `json:"rule_id" binding:"required"`
Target string `json:"target,omitempty"` // e.g., "ARGS:password"
Description string `json:"description,omitempty"` // Human-readable reason
}
// WAFExclusion represents a stored rule exclusion
type WAFExclusion struct {
RuleID int `json:"rule_id"`
Target string `json:"target,omitempty"`
Description string `json:"description,omitempty"`
}
// CreateDecisionRequest is the client-facing DTO for manual decision creation.
// Enrichment fields (Scenario, Country, ExpiresAt) are system-populated and
// deliberately excluded to prevent clients from injecting arbitrary values.
type CreateDecisionRequest struct {
IP string `json:"ip" binding:"required"`
Action string `json:"action" binding:"required"`
Host string `json:"host,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Details string `json:"details,omitempty"`
}
// SecurityHandler handles security-related API requests.
type SecurityHandler struct {
cfg config.SecurityConfig
db *gorm.DB
svc *services.SecurityService
caddyManager *caddy.Manager
geoipSvc *services.GeoIPService
cerberus CacheInvalidator
}
// NewSecurityHandler creates a new SecurityHandler.
func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager) *SecurityHandler {
svc := services.NewSecurityService(db)
return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager}
}
// NewSecurityHandlerWithDeps creates a new SecurityHandler with optional cache invalidation.
func NewSecurityHandlerWithDeps(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager, cerberus CacheInvalidator) *SecurityHandler {
svc := services.NewSecurityService(db)
return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager, cerberus: cerberus}
}
// SetGeoIPService sets the GeoIP service for the handler.
func (h *SecurityHandler) SetGeoIPService(geoipSvc *services.GeoIPService) {
h.geoipSvc = geoipSvc
}
// GetStatus returns the current status of all security services.
// Priority chain:
// 1. Settings table (highest - runtime overrides)
// 2. SecurityConfig DB record (middle - user configuration)
// 3. Static config (lowest - defaults)
func (h *SecurityHandler) GetStatus(c *gin.Context) {
// Start with static config defaults
enabled := h.cfg.CerberusEnabled
wafMode := h.cfg.WAFMode
rateLimitMode := h.cfg.RateLimitMode
crowdSecMode := h.cfg.CrowdSecMode
crowdSecAPIURL := h.cfg.CrowdSecAPIURL
aclMode := h.cfg.ACLMode
// Override with database SecurityConfig if present (priority 2)
if h.db != nil {
var sc models.SecurityConfig
if err := h.db.Where("name = ?", "default").First(&sc).Error; err == nil {
// SecurityConfig in DB takes precedence over static config
enabled = sc.Enabled
if sc.WAFMode != "" {
wafMode = sc.WAFMode
}
if sc.RateLimitMode != "" {
rateLimitMode = sc.RateLimitMode
} else if sc.RateLimitEnable {
rateLimitMode = "enabled"
}
if sc.CrowdSecMode != "" {
crowdSecMode = sc.CrowdSecMode
}
if sc.CrowdSecAPIURL != "" {
crowdSecAPIURL = sc.CrowdSecAPIURL
}
}
// Check runtime setting overrides from settings table (priority 1 - highest)
var setting struct{ Value string }
// Cerberus enabled override
cerberusOverrideApplied := false
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "feature.cerberus.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
enabled = strings.EqualFold(setting.Value, "true")
cerberusOverrideApplied = true
}
// Backward-compatible Cerberus enabled override
if !cerberusOverrideApplied {
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.cerberus.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
enabled = strings.EqualFold(setting.Value, "true")
}
}
// WAF enabled override
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.waf.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
if strings.EqualFold(setting.Value, "true") {
wafMode = "enabled"
} else {
wafMode = "disabled"
}
}
// Rate Limit enabled override
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.rate_limit.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
if strings.EqualFold(setting.Value, "true") {
rateLimitMode = "enabled"
} else {
rateLimitMode = "disabled"
}
}
// CrowdSec enabled override
crowdSecEnabledOverride := false
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
crowdSecEnabledOverride = true
if strings.EqualFold(setting.Value, "true") {
crowdSecMode = "local"
} else {
crowdSecMode = "disabled"
}
}
// CrowdSec mode override (deprecated - only applies when enabled override is absent)
if !crowdSecEnabledOverride {
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&setting).Error; err == nil && setting.Value != "" {
crowdSecMode = setting.Value
}
}
// ACL enabled override
setting = struct{ Value string }{}
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.acl.enabled").Scan(&setting).Error; err == nil && setting.Value != "" {
if strings.EqualFold(setting.Value, "true") {
aclMode = "enabled"
} else {
aclMode = "disabled"
}
}
}
// Map unknown/external mode to disabled
if crowdSecMode != "local" && crowdSecMode != "disabled" {
crowdSecMode = "disabled"
}
// Compute effective enabled state for each feature
wafEnabled := wafMode != "" && wafMode != "disabled"
rateLimitEnabled := rateLimitMode == "enabled"
crowdsecEnabled := crowdSecMode == "local"
aclEnabled := aclMode == "enabled"
// All features require Cerberus to be enabled
if !enabled {
wafEnabled = false
rateLimitEnabled = false
crowdsecEnabled = false
aclEnabled = false
wafMode = "disabled"
rateLimitMode = "disabled"
crowdSecMode = "disabled"
aclMode = "disabled"
}
c.JSON(http.StatusOK, gin.H{
"cerberus": gin.H{"enabled": enabled},
"crowdsec": gin.H{
"mode": crowdSecMode,
"api_url": crowdSecAPIURL,
"enabled": crowdsecEnabled,
},
"waf": gin.H{
"mode": wafMode,
"enabled": wafEnabled,
},
"rate_limit": gin.H{
"mode": rateLimitMode,
"enabled": rateLimitEnabled,
},
"acl": gin.H{
"mode": aclMode,
"enabled": aclEnabled,
},
"config_apply": latestConfigApplyState(h.db),
})
}
func latestConfigApplyState(db *gorm.DB) gin.H {
state := gin.H{
"available": false,
"status": "unknown",
}
if db == nil {
return state
}
var latest models.CaddyConfig
err := db.Order("applied_at desc").First(&latest).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return state
}
return state
}
status := "failed"
if latest.Success {
status = "applied"
}
state["available"] = true
state["status"] = status
state["success"] = latest.Success
state["applied_at"] = latest.AppliedAt
state["error_msg"] = latest.ErrorMsg
return state
}
// GetConfig returns the site security configuration from DB or default
func (h *SecurityHandler) GetConfig(c *gin.Context) {
cfg, err := h.svc.Get()
if err != nil {
if err == services.ErrSecurityConfigNotFound {
c.JSON(http.StatusOK, gin.H{"config": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return
}
c.JSON(http.StatusOK, gin.H{"config": cfg})
}
// UpdateConfig creates or updates the SecurityConfig in DB
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
if !requireAdmin(c) {
return
}
var payload models.SecurityConfig
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.Name == "" {
payload.Name = "default"
}
// Sync RateLimitMode with RateLimitEnable for backward compatibility
if payload.RateLimitEnable {
payload.RateLimitMode = "enabled"
} else if payload.RateLimitMode == "" {
payload.RateLimitMode = "disabled"
}
if err := h.svc.Upsert(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply updated config to Caddy so WAF mode changes take effect
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
log.WithError(err).Warn("failed to apply security config changes to Caddy")
}
}
c.JSON(http.StatusOK, gin.H{"config": payload})
}
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
if !requireAdmin(c) {
return
}
token, err := h.svc.GenerateBreakGlassToken("default")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
// ListDecisions returns recent security decisions
func (h *SecurityHandler) ListDecisions(c *gin.Context) {
limit := 50
if q := c.Query("limit"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
limit = v
}
}
list, err := h.svc.ListDecisions(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
return
}
c.JSON(http.StatusOK, gin.H{"decisions": list})
}
// CreateDecision creates a manual decision (override) - for now no checks besides payload
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req CreateDecisionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.IP == "" || req.Action == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
return
}
// CRITICAL: Validate IP format to prevent SQL injection via IP field
// Must accept both single IPs and CIDR ranges
if !isValidIP(req.IP) && !isValidCIDR(req.IP) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address format"})
return
}
// CRITICAL: Validate action enum
// Only accept known action types to prevent injection via action field
validActions := []string{"block", "allow", "captcha"}
if !contains(validActions, req.Action) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
return
}
// Map DTO to model — enrichment fields (Scenario, Country, ExpiresAt)
// are intentionally excluded; they are system-populated only.
payload := models.SecurityDecision{
IP: req.IP,
Action: req.Action,
Host: req.Host,
RuleID: req.RuleID,
Details: sanitizeString(req.Details, 1000),
Source: "manual",
}
if err := h.svc.LogDecision(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
return
}
// Record an audit entry
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
c.JSON(http.StatusOK, gin.H{"decision": payload})
}
// ListRuleSets returns the list of known rulesets
func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
list, err := h.svc.ListRuleSets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
return
}
c.JSON(http.StatusOK, gin.H{"rulesets": list})
}
// UpsertRuleSet uploads or updates a ruleset
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
if !requireAdmin(c) {
return
}
var payload models.SecurityRuleSet
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
if err := h.svc.UpsertRuleSet(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Create an audit event
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
c.JSON(http.StatusOK, gin.H{"ruleset": payload})
}
// DeleteRuleSet removes a ruleset by id
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
if !requireAdmin(c) {
return
}
idParam := c.Param("id")
if idParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.svc.DeleteRuleSet(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// Enable toggles Cerberus on, validating admin whitelist or break-glass token
func (h *SecurityHandler) Enable(c *gin.Context) {
// Look for requester's IP and optional breakglass token
adminIP := c.ClientIP()
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&body)
// If config exists, require that adminIP is in whitelist or token matches
cfg, err := h.svc.Get()
if err != nil && err != services.ErrSecurityConfigNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve security config"})
return
}
if cfg != nil {
// Check admin whitelist
if cfg.AdminWhitelist == "" && body.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "admin whitelist missing; provide break_glass_token or add admin_whitelist CIDR before enabling"})
return
}
if body.Token != "" {
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err == nil && ok {
// proceed
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}
} else {
// verify client IP in admin whitelist
found := false
for _, entry := range strings.Split(cfg.AdminWhitelist, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if entry == adminIP {
found = true
break
}
// If CIDR, check contains
if _, cidr, err := net.ParseCIDR(entry); err == nil {
if cidr.Contains(net.ParseIP(adminIP)) {
found = true
break
}
}
}
if !found {
c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
return
}
}
}
// Set enabled true
newCfg := &models.SecurityConfig{Name: "default", Enabled: true}
if cfg != nil {
newCfg = cfg
newCfg.Enabled = true
}
if err := h.svc.Upsert(newCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable Cerberus"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"enabled": true})
}
// Disable toggles Cerberus off; requires break-glass token or localhost request
func (h *SecurityHandler) Disable(c *gin.Context) {
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&body)
// Allow requests from localhost to disable without token
clientIP := c.ClientIP()
if clientIP == "127.0.0.1" || clientIP == "::1" {
cfg, _ := h.svc.Get()
if cfg == nil {
cfg = &models.SecurityConfig{Name: "default", Enabled: false}
} else {
cfg.Enabled = false
}
_ = h.svc.Upsert(cfg)
if h.caddyManager != nil {
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}
c.JSON(http.StatusOK, gin.H{"enabled": false})
return
}
cfg, err := h.svc.Get()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
return
}
if body.Token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token required to disable Cerberus from non-localhost"})
return
}
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err != nil || !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}
cfg.Enabled = false
_ = h.svc.Upsert(cfg)
if h.caddyManager != nil {
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}
c.JSON(http.StatusOK, gin.H{"enabled": false})
}
// GetRateLimitPresets returns predefined rate limit configurations
func (h *SecurityHandler) GetRateLimitPresets(c *gin.Context) {
presets := []map[string]any{
{
"id": "standard",
"name": "Standard Web",
"description": "Balanced protection for general web applications",
"requests": 100,
"window_sec": 60,
"burst": 20,
},
{
"id": "api",
"name": "API Protection",
"description": "Stricter limits for API endpoints",
"requests": 30,
"window_sec": 60,
"burst": 10,
},
{
"id": "login",
"name": "Login Protection",
"description": "Aggressive protection against brute-force",
"requests": 5,
"window_sec": 300,
"burst": 2,
},
{
"id": "relaxed",
"name": "High Traffic",
"description": "Higher limits for trusted, high-traffic apps",
"requests": 500,
"window_sec": 60,
"burst": 100,
},
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// GetGeoIPStatus returns the current status of the GeoIP service.
func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
if h.geoipSvc == nil {
c.JSON(http.StatusOK, gin.H{
"loaded": false,
"message": "GeoIP service not initialized",
"db_path": "",
})
return
}
c.JSON(http.StatusOK, gin.H{
"loaded": h.geoipSvc.IsLoaded(),
"db_path": h.geoipSvc.GetDatabasePath(),
"message": "GeoIP service available",
})
}
// ReloadGeoIP reloads the GeoIP database from disk.
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
if !requireAdmin(c) {
return
}
if h.geoipSvc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GeoIP service not initialized",
})
return
}
if err := h.geoipSvc.Load(); err != nil {
log.WithError(err).Error("Failed to reload GeoIP database")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to reload GeoIP database: " + err.Error(),
})
return
}
// Log audit event
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "reload_geoip", Details: "GeoIP database reloaded successfully"})
c.JSON(http.StatusOK, gin.H{
"message": "GeoIP database reloaded successfully",
"loaded": h.geoipSvc.IsLoaded(),
"db_path": h.geoipSvc.GetDatabasePath(),
})
}
// LookupGeoIP performs a GeoIP lookup for a given IP address.
func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
IPAddress string `json:"ip_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip_address is required"})
return
}
if h.geoipSvc == nil || !h.geoipSvc.IsLoaded() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GeoIP service not available",
})
return
}
country, err := h.geoipSvc.LookupCountry(req.IPAddress)
if err != nil {
if errors.Is(err, services.ErrInvalidGeoIP) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP address"})
return
}
if errors.Is(err, services.ErrCountryNotFound) {
c.JSON(http.StatusOK, gin.H{
"ip_address": req.IPAddress,
"country_code": "",
"found": false,
"message": "No country found for this IP address",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "GeoIP lookup failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ip_address": req.IPAddress,
"country_code": country,
"found": true,
})
}
// GetWAFExclusions returns current WAF rule exclusions from SecurityConfig
func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
cfg, err := h.svc.Get()
if err != nil {
if err == services.ErrSecurityConfigNotFound {
c.JSON(http.StatusOK, gin.H{"exclusions": []WAFExclusion{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return
}
var exclusions []WAFExclusion
if cfg.WAFExclusions != "" {
if err := json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions); err != nil {
log.WithError(err).Warn("Failed to parse WAF exclusions")
exclusions = []WAFExclusion{}
}
}
c.JSON(http.StatusOK, gin.H{"exclusions": exclusions})
}
// AddWAFExclusion adds a rule exclusion to the WAF configuration
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req WAFExclusionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
return
}
if req.RuleID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id must be a positive integer"})
return
}
cfg, err := h.svc.Get()
if err != nil {
if err == services.ErrSecurityConfigNotFound {
// Create default config with the exclusion
cfg = &models.SecurityConfig{Name: "default"}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return
}
}
// Parse existing exclusions
var exclusions []WAFExclusion
if cfg.WAFExclusions != "" {
if unmarshalErr := json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions); unmarshalErr != nil {
log.WithError(unmarshalErr).Warn("Failed to parse existing WAF exclusions")
exclusions = []WAFExclusion{}
}
}
// Check for duplicate rule_id with same target
for _, e := range exclusions {
if e.RuleID == req.RuleID && e.Target == req.Target {
c.JSON(http.StatusConflict, gin.H{"error": "exclusion for this rule_id and target already exists"})
return
}
}
// Add the new exclusion - convert request to WAFExclusion type
newExclusion := WAFExclusion(req)
exclusions = append(exclusions, newExclusion)
// Marshal back to JSON
exclusionsJSON, err := json.Marshal(exclusions)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize exclusions"})
return
}
cfg.WAFExclusions = string(exclusionsJSON)
if err := h.svc.Upsert(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save exclusion"})
return
}
// Apply updated config to Caddy
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
log.WithError(err).Warn("failed to apply WAF exclusion changes to Caddy")
}
}
// Log audit event
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{
Actor: actor,
Action: "add_waf_exclusion",
Details: strconv.Itoa(req.RuleID),
})
c.JSON(http.StatusOK, gin.H{"exclusion": newExclusion})
}
// DeleteWAFExclusion removes a rule exclusion by rule_id
func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) {
if !requireAdmin(c) {
return
}
ruleIDParam := c.Param("rule_id")
if ruleIDParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
return
}
ruleID, err := strconv.Atoi(ruleIDParam)
if err != nil || ruleID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid rule_id"})
return
}
// Get optional target query parameter (for exclusions with specific targets)
target := c.Query("target")
cfg, err := h.svc.Get()
if err != nil {
if err == services.ErrSecurityConfigNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "exclusion not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return
}
// Parse existing exclusions
var exclusions []WAFExclusion
if cfg.WAFExclusions != "" {
if unmarshalErr := json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions); unmarshalErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse exclusions"})
return
}
}
// Find and remove the exclusion
found := false
newExclusions := make([]WAFExclusion, 0, len(exclusions))
for _, e := range exclusions {
// Match by rule_id and target (empty target matches exclusions without target)
if e.RuleID == ruleID && e.Target == target {
found = true
continue // Skip this one (delete it)
}
newExclusions = append(newExclusions, e)
}
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "exclusion not found"})
return
}
// Marshal back to JSON
exclusionsJSON, err := json.Marshal(newExclusions)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize exclusions"})
return
}
cfg.WAFExclusions = string(exclusionsJSON)
if err := h.svc.Upsert(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save exclusions"})
return
}
// Apply updated config to Caddy
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
log.WithError(err).Warn("failed to apply WAF exclusion changes to Caddy")
}
}
// Log audit event
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{
Actor: actor,
Action: "delete_waf_exclusion",
Details: ruleIDParam,
})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// isValidIP validates that s is a valid IPv4 or IPv6 address
func isValidIP(s string) bool {
return net.ParseIP(s) != nil
}
// isValidCIDR validates that s is a valid CIDR notation
func isValidCIDR(s string) bool {
_, _, err := net.ParseCIDR(s)
return err == nil
}
// contains checks if a string exists in a slice
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// sanitizeString removes control characters and enforces max length
func sanitizeString(s string, maxLen int) string {
// Remove null bytes and other control characters
s = strings.Map(func(r rune) rune {
if r == 0 || (r < 32 && r != '\n' && r != '\r' && r != '\t') {
return -1 // Remove character
}
return r
}, s)
// Enforce max length
if len(s) > maxLen {
return s[:maxLen]
}
return s
}
// Security module enable/disable endpoints (Phase 2)
// These endpoints allow granular control over individual security modules
// EnableACL enables the Access Control List security module
// POST /api/v1/security/acl/enable
func (h *SecurityHandler) EnableACL(c *gin.Context) {
h.toggleSecurityModule(c, "security.acl.enabled", true)
}
// DisableACL disables the Access Control List security module
// POST /api/v1/security/acl/disable
func (h *SecurityHandler) DisableACL(c *gin.Context) {
h.toggleSecurityModule(c, "security.acl.enabled", false)
}
// PatchACL handles PATCH requests to enable/disable ACL based on JSON body
// PATCH /api/v1/security/acl
// Expects: {"enabled": true/false}
func (h *SecurityHandler) PatchACL(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.toggleSecurityModule(c, "security.acl.enabled", req.Enabled)
}
// EnableWAF enables the Web Application Firewall security module
// POST /api/v1/security/waf/enable
func (h *SecurityHandler) EnableWAF(c *gin.Context) {
h.toggleSecurityModule(c, "security.waf.enabled", true)
}
// DisableWAF disables the Web Application Firewall security module
// POST /api/v1/security/waf/disable
func (h *SecurityHandler) DisableWAF(c *gin.Context) {
h.toggleSecurityModule(c, "security.waf.enabled", false)
}
// PatchWAF handles PATCH requests to enable/disable WAF based on JSON body
// PATCH /api/v1/security/waf
// Expects: {"enabled": true/false}
func (h *SecurityHandler) PatchWAF(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.toggleSecurityModule(c, "security.waf.enabled", req.Enabled)
}
// EnableCerberus enables the Cerberus security monitoring module
// POST /api/v1/security/cerberus/enable
func (h *SecurityHandler) EnableCerberus(c *gin.Context) {
h.toggleSecurityModule(c, "feature.cerberus.enabled", true)
}
// DisableCerberus disables the Cerberus security monitoring module
// POST /api/v1/security/cerberus/disable
func (h *SecurityHandler) DisableCerberus(c *gin.Context) {
h.toggleSecurityModule(c, "feature.cerberus.enabled", false)
}
// EnableCrowdSec enables the CrowdSec security module
// POST /api/v1/security/crowdsec/enable
func (h *SecurityHandler) EnableCrowdSec(c *gin.Context) {
h.toggleSecurityModule(c, "security.crowdsec.enabled", true)
}
// DisableCrowdSec disables the CrowdSec security module
// POST /api/v1/security/crowdsec/disable
func (h *SecurityHandler) DisableCrowdSec(c *gin.Context) {
h.toggleSecurityModule(c, "security.crowdsec.enabled", false)
}
// PatchCrowdSec handles PATCH requests to enable/disable CrowdSec based on JSON body
// PATCH /api/v1/security/crowdsec
// Expects: {"enabled": true/false}
func (h *SecurityHandler) PatchCrowdSec(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.toggleSecurityModule(c, "security.crowdsec.enabled", req.Enabled)
}
// EnableRateLimit enables the Rate Limiting security module
// POST /api/v1/security/rate-limit/enable
func (h *SecurityHandler) EnableRateLimit(c *gin.Context) {
h.toggleSecurityModule(c, "security.rate_limit.enabled", true)
}
// DisableRateLimit disables the Rate Limiting security module
// POST /api/v1/security/rate-limit/disable
func (h *SecurityHandler) DisableRateLimit(c *gin.Context) {
h.toggleSecurityModule(c, "security.rate_limit.enabled", false)
}
// PatchRateLimit handles PATCH requests to enable/disable Rate Limiting based on JSON body
// PATCH /api/v1/security/rate-limit
// Expects: {"enabled": true/false}
func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.toggleSecurityModule(c, "security.rate_limit.enabled", req.Enabled)
}
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
// It updates the setting, invalidates cache, and triggers Caddy config reload
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
if !requireAdmin(c) {
return
}
settingCategory := "security"
if strings.HasPrefix(settingKey, "feature.") {
settingCategory = "feature"
}
snapshotKeys := []string{settingKey}
if enabled && settingKey != "feature.cerberus.enabled" {
snapshotKeys = append(snapshotKeys, "feature.cerberus.enabled", "security.cerberus.enabled")
}
settingSnapshots, err := h.snapshotSettings(snapshotKeys)
if err != nil {
log.WithError(err).Error("Failed to snapshot security settings before toggle")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security module"})
return
}
securityConfigExistsBefore, securityConfigEnabledBefore, err := h.snapshotDefaultSecurityConfigState()
if err != nil {
log.WithError(err).Error("Failed to snapshot security config before toggle")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security module"})
return
}
if settingKey == "security.acl.enabled" && enabled {
if !h.allowACLEnable(c) {
return
}
}
if enabled && settingKey != "feature.cerberus.enabled" {
if err := h.ensureSecurityConfigEnabled(); err != nil {
log.WithError(err).Error("Failed to enable SecurityConfig while enabling security module")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
cerberusSetting := models.Setting{
Key: "feature.cerberus.enabled",
Value: "true",
Category: "feature",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil {
log.WithError(err).Error("Failed to enable Cerberus while enabling security module")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
legacyCerberus := models.Setting{
Key: "security.cerberus.enabled",
Value: "true",
Category: "security",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil {
log.WithError(err).Error("Failed to enable legacy Cerberus while enabling security module")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
}
if settingKey == "security.acl.enabled" && enabled {
if err := h.ensureSecurityConfigEnabled(); err != nil {
log.WithError(err).Error("Failed to enable SecurityConfig while enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
cerberusSetting := models.Setting{
Key: "feature.cerberus.enabled",
Value: "true",
Category: "feature",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil {
log.WithError(err).Error("Failed to enable Cerberus while enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
legacyCerberus := models.Setting{
Key: "security.cerberus.enabled",
Value: "true",
Category: "security",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil {
log.WithError(err).Error("Failed to enable legacy Cerberus while enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
}
// Update setting
value := "false"
if enabled {
value = "true"
}
setting := models.Setting{
Key: settingKey,
Value: value,
Category: settingCategory,
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: settingKey}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
log.WithError(err).Errorf("Failed to update setting %s", settingKey)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security module"})
return
}
if settingKey == "feature.cerberus.enabled" {
legacyCerberus := models.Setting{
Key: "security.cerberus.enabled",
Value: value,
Category: "security",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil {
log.WithError(err).Error("Failed to sync legacy Cerberus setting")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security module"})
return
}
}
if settingKey == "security.acl.enabled" && enabled {
var count int64
if err := h.db.Model(&models.SecurityConfig{}).Count(&count).Error; err != nil {
log.WithError(err).Error("Failed to count security configs after enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
if count == 0 {
cfg := models.SecurityConfig{Name: "default", Enabled: true}
if err := h.db.Create(&cfg).Error; err != nil {
log.WithError(err).Error("Failed to create security config after enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
} else {
if err := h.db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Update("enabled", true).Error; err != nil {
log.WithError(err).Error("Failed to update security config after enabling ACL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
}
}
if h.cerberus != nil {
h.cerberus.InvalidateCache()
}
// Trigger Caddy config reload
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
log.WithError(err).Warn("Failed to reload Caddy config after security module toggle")
if restoreErr := h.restoreSettings(settingSnapshots); restoreErr != nil {
log.WithError(restoreErr).Error("Failed to restore settings after security module toggle apply failure")
}
if restoreErr := h.restoreDefaultSecurityConfigState(securityConfigExistsBefore, securityConfigEnabledBefore); restoreErr != nil {
log.WithError(restoreErr).Error("Failed to restore security config after security module toggle apply failure")
}
if h.cerberus != nil {
h.cerberus.InvalidateCache()
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload configuration"})
return
}
}
log.Info("Security module toggled")
c.JSON(http.StatusOK, gin.H{
"success": true,
"module": settingKey,
"enabled": enabled,
"applied": true,
})
}
type settingSnapshot struct {
exists bool
setting models.Setting
}
func (h *SecurityHandler) snapshotSettings(keys []string) (map[string]settingSnapshot, error) {
snapshots := make(map[string]settingSnapshot, len(keys))
for _, key := range keys {
if _, exists := snapshots[key]; exists {
continue
}
var existing models.Setting
err := h.db.Where("key = ?", key).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
snapshots[key] = settingSnapshot{exists: false}
continue
}
if err != nil {
return nil, err
}
snapshots[key] = settingSnapshot{exists: true, setting: existing}
}
return snapshots, nil
}
func (h *SecurityHandler) restoreSettings(snapshots map[string]settingSnapshot) error {
for key, snapshot := range snapshots {
if snapshot.exists {
restore := snapshot.setting
if err := h.db.Where(models.Setting{Key: key}).Assign(restore).FirstOrCreate(&restore).Error; err != nil {
return err
}
continue
}
if err := h.db.Where("key = ?", key).Delete(&models.Setting{}).Error; err != nil {
return err
}
}
return nil
}
func (h *SecurityHandler) snapshotDefaultSecurityConfigState() (bool, bool, error) {
var cfg models.SecurityConfig
err := h.db.Where("name = ?", "default").First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
if err != nil {
return false, false, err
}
return true, cfg.Enabled, nil
}
func (h *SecurityHandler) restoreDefaultSecurityConfigState(exists bool, enabled bool) error {
if exists {
return h.db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Update("enabled", enabled).Error
}
return h.db.Where("name = ?", "default").Delete(&models.SecurityConfig{}).Error
}
func (h *SecurityHandler) ensureSecurityConfigEnabled() error {
if h.db == nil {
return errors.New("security config database not configured")
}
cfg := models.SecurityConfig{Name: "default", Enabled: true}
if err := h.db.Where("name = ?", "default").FirstOrCreate(&cfg).Error; err != nil {
return err
}
if cfg.Enabled {
return nil
}
return h.db.Model(&cfg).Update("enabled", true).Error
}
func (h *SecurityHandler) allowACLEnable(c *gin.Context) bool {
if bypass, exists := c.Get("emergency_bypass"); exists {
if bypassActive, ok := bypass.(bool); ok && bypassActive {
return true
}
}
cfg, err := h.svc.Get()
if err != nil {
if errors.Is(err, services.ErrSecurityConfigNotFound) {
return true
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return false
}
whitelist := strings.TrimSpace(cfg.AdminWhitelist)
if whitelist == "" {
return true
}
clientIP := util.CanonicalizeIPForSecurity(c.ClientIP())
if securitypkg.IsIPInCIDRList(clientIP, whitelist) {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
return false
}