chore: clean .gitignore cache
This commit is contained in:
@@ -1,853 +0,0 @@
|
||||
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"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SecurityHandler handles security-related API requests.
|
||||
type SecurityHandler struct {
|
||||
cfg config.SecurityConfig
|
||||
db *gorm.DB
|
||||
svc *services.SecurityService
|
||||
caddyManager *caddy.Manager
|
||||
geoipSvc *services.GeoIPService
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
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 != "" {
|
||||
if strings.EqualFold(setting.Value, "true") {
|
||||
crowdSecMode = "local"
|
||||
} else {
|
||||
crowdSecMode = "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
// CrowdSec mode override
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
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) {
|
||||
var payload models.SecurityDecision
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if payload.IP == "" || payload.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(payload.IP) && !isValidCIDR(payload.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, payload.Action) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize details field (limit length, strip control characters)
|
||||
payload.Details = sanitizeString(payload.Details, 1000)
|
||||
|
||||
// Populate source
|
||||
payload.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) {
|
||||
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) {
|
||||
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 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) {
|
||||
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) {
|
||||
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 err := json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions); err != nil {
|
||||
log.WithError(err).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) {
|
||||
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 err := json.Unmarshal([]byte(cfg.WAFExclusions), &exclusions); err != 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
|
||||
}
|
||||
Reference in New Issue
Block a user