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" intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/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) // Benutzerdefinierte App-Profile (wiederverwendbare Rule-ID-Bündel). g.GET("/profiles", h.ListProfiles) g.POST("/profiles", h.CreateProfile) g.PUT("/profiles/:id", h.UpdateProfile) g.DELETE("/profiles/:id", h.DeleteProfile) } // 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"` CRSPlugins []string `json:"crs_plugins"` AppProfiles []string `json:"app_profiles"` 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.CRSPlugins == nil { body.CRSPlugins = []string{} } if body.TrustedProxies == nil { body.TrustedProxies = []string{} } if body.AppProfiles == nil { body.AppProfiles = []string{} } // App-Profile: nur trimmen/leere raus. Unbekannte Namen sind harmlos (der // Agent-Resolver ignoriert sie defensiv), aber wir speichern keinen Müll. cleanProfiles := make([]string, 0, len(body.AppProfiles)) for _, p := range body.AppProfiles { if p = strings.TrimSpace(p); p != "" { cleanProfiles = append(cleanProfiles, p) } } body.AppProfiles = cleanProfiles // CRS-Plugins müssen aus der bekannten Whitelist stammen — sie werden zu // Include-Pfaden, ein unbekannter Name wäre Pfad-Injection. for _, p := range body.CRSPlugins { if _, ok := intwaf.KnownCRSPlugins[strings.TrimSpace(p)]; !ok { response.BadRequest(c, errors.New("unbekanntes CRS-Plugin: "+p)) return } } 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, CRSPlugins: body.CRSPlugins, AppProfiles: body.AppProfiles, 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}) } // wafProfileNameRe: erlaubte Zeichen für App-Profil-Namen (der Name wird pro // Domain in waf_configs.app_profiles referenziert; kein SecLang-Kontext, aber // sauber begrenzen). var wafProfileNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,60}$`) // profileBody ist das akzeptierte JSON für Create/Update eines App-Profils. type profileBody struct { Name string `json:"name"` Description string `json:"description"` RuleExclusions []string `json:"rule_exclusions"` } // validateProfileBody normalisiert und prüft den Request-Body. Gibt eine // Fehlermeldung zurück (nil = ok) und mutiert body (trim, nil→[]). func validateProfileBody(body *profileBody) error { body.Name = strings.TrimSpace(body.Name) if !wafProfileNameRe.MatchString(body.Name) { return errors.New("ungültiger Profil-Name (1–60 Zeichen: Buchstaben, Ziffern, Leer, . _ -)") } body.Description = strings.TrimSpace(body.Description) if body.RuleExclusions == nil { body.RuleExclusions = []string{} } for i, ex := range body.RuleExclusions { ex = strings.TrimSpace(ex) if !wafRuleIDRe.MatchString(ex) { return errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): " + ex) } body.RuleExclusions[i] = ex } return nil } // ListProfiles returns all WAF app profiles (built-in first). func (h *WafHandler) ListProfiles(c *gin.Context) { profiles, err := h.Repo.ListProfiles(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"profiles": profiles}) } // CreateProfile creates a new user-defined app profile. func (h *WafHandler) CreateProfile(c *gin.Context) { var body profileBody if err := c.ShouldBindJSON(&body); err != nil { response.BadRequest(c, err) return } if err := validateProfileBody(&body); err != nil { response.BadRequest(c, err) return } p, err := h.Repo.CreateProfile(c.Request.Context(), body.Name, body.Description, body.RuleExclusions) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.create", body.Name, gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID) c.JSON(http.StatusOK, gin.H{"profile": p}) } // UpdateProfile updates a user-defined app profile (built-ins are read-only). func (h *WafHandler) UpdateProfile(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, errors.New("invalid id")) return } var body profileBody if err := c.ShouldBindJSON(&body); err != nil { response.BadRequest(c, err) return } if err := validateProfileBody(&body); err != nil { response.BadRequest(c, err) return } p, err := h.Repo.UpdateProfile(c.Request.Context(), id, body.Name, body.Description, body.RuleExclusions) if err != nil { if errors.Is(err, wafsvc.ErrProfileNotFound) { response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)")) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.update", body.Name, gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID) c.JSON(http.StatusOK, gin.H{"profile": p}) } // DeleteProfile removes a user-defined app profile (built-ins are protected). func (h *WafHandler) DeleteProfile(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, errors.New("invalid id")) return } if err := h.Repo.DeleteProfile(c.Request.Context(), id); err != nil { if errors.Is(err, wafsvc.ErrProfileNotFound) { response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)")) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.delete", strconv.FormatInt(id, 10), nil, h.NodeID) response.OK(c, gin.H{"ok": true}) } // 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{}, CRSPlugins: []string{}, AppProfiles: []string{}, ExclusionNotes: map[string]string{}, TrustedProxies: []string{}, CustomRules: "", } }