package handlers import ( "context" "errors" "log/slog" "net" "net/http" "regexp" "strconv" "strings" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" ) // wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" / // "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection. var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`) // WafHandler exposes the per-domain WAF configuration REST API: // // GET /waf/configs — list all configs (one per domain) // GET /waf/configs/:domain_id — get config for a domain // PUT /waf/configs/:domain_id — upsert config for a domain type WafHandler struct { Repo *wafsvc.Repo Audit *audit.Repo NodeID string Reloader func(ctx context.Context) error } func NewWafHandler(repo *wafsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *WafHandler { return &WafHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader} } func (h *WafHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/waf") g.GET("/configs", h.List) g.GET("/configs/:domain_id", h.Get) g.PUT("/configs/:domain_id", h.Upsert) g.GET("/alerts", h.ListAlerts) g.DELETE("/alerts", h.PurgeAlerts) } // List returns all WAF configs. func (h *WafHandler) List(c *gin.Context) { configs, err := h.Repo.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"configs": configs}) } // Get returns the WAF config for a single domain. // Returns a default (disabled) config when none exists yet. func (h *WafHandler) Get(c *gin.Context) { domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64) if err != nil { response.BadRequest(c, errors.New("invalid domain_id")) return } cfg, err := h.Repo.GetByDomain(c.Request.Context(), domainID) if err != nil { if errors.Is(err, wafsvc.ErrNotFound) { // Return a default config so the UI always gets a usable object. response.OK(c, gin.H{"config": defaultConfig(domainID)}) return } response.Internal(c, err) return } response.OK(c, gin.H{"config": cfg}) } // upsertBody is the accepted JSON for PUT /waf/configs/:domain_id. type upsertBody struct { Enabled bool `json:"enabled"` Mode string `json:"mode"` ParanoiaLevel int `json:"paranoia_level"` RuleExclusions []string `json:"rule_exclusions"` ExclusionNotes map[string]string `json:"exclusion_notes"` TrustedProxies []string `json:"trusted_proxies"` CustomRules string `json:"custom_rules"` } // Upsert creates or updates the WAF config for a domain. func (h *WafHandler) Upsert(c *gin.Context) { domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64) if err != nil { response.BadRequest(c, errors.New("invalid domain_id")) return } var body upsertBody if err := c.ShouldBindJSON(&body); err != nil { response.BadRequest(c, err) return } if body.Mode == "" { body.Mode = "detection" } if body.ParanoiaLevel < 1 || body.ParanoiaLevel > 4 { body.ParanoiaLevel = 1 } if body.RuleExclusions == nil { body.RuleExclusions = []string{} } if body.TrustedProxies == nil { body.TrustedProxies = []string{} } if body.ExclusionNotes == nil { body.ExclusionNotes = map[string]string{} } // Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection // in die SecLang-Config via Newline). for _, ex := range body.RuleExclusions { if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) { response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex)) return } } // Trusted-Proxies müssen gültige IPs/CIDRs sein. for _, p := range body.TrustedProxies { p = strings.TrimSpace(p) if p == "" { continue } if net.ParseIP(p) == nil { if _, _, err := net.ParseCIDR(p); err != nil { response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p)) return } } } cfg := models.WafConfig{ DomainID: domainID, Enabled: body.Enabled, Mode: body.Mode, ParanoiaLevel: body.ParanoiaLevel, RuleExclusions: body.RuleExclusions, ExclusionNotes: body.ExclusionNotes, TrustedProxies: body.TrustedProxies, CustomRules: body.CustomRules, } result, err := h.Repo.Upsert(c.Request.Context(), cfg) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert", strconv.FormatInt(domainID, 10), gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel}, h.NodeID) // Reload HAProxy so the SPOE filter is added/removed based on // whether any domain now has WAF enabled. if h.Reloader != nil { go func() { if err := h.Reloader(context.Background()); err != nil { slog.Warn("waf: haproxy reload after config change failed", "error", err) } }() } c.JSON(http.StatusOK, gin.H{"config": result}) } // ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N func (h *WafHandler) ListAlerts(c *gin.Context) { var domainID *int64 if v := c.Query("domain_id"); v != "" { id, err := strconv.ParseInt(v, 10, 64) if err != nil { response.BadRequest(c, errors.New("invalid domain_id")) return } domainID = &id } limit := 200 if v := c.Query("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { limit = n } } alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"alerts": alerts}) } // PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30) func (h *WafHandler) PurgeAlerts(c *gin.Context) { days := 30 if v := c.Query("days"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { days = n } } if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge", "", gin.H{"days": days}, h.NodeID) response.OK(c, gin.H{"ok": true, "days": days}) } // defaultConfig returns a sensible disabled default for a domain // that has no WAF config row yet. func defaultConfig(domainID int64) models.WafConfig { return models.WafConfig{ DomainID: domainID, Enabled: false, Mode: "detection", ParanoiaLevel: 1, RuleExclusions: []string{}, ExclusionNotes: map[string]string{}, TrustedProxies: []string{}, CustomRules: "", } }