feat(waf): benutzerdefinierte App-Profile + Fix: CRS-Plugins wurden im Agent nie geladen — v1.3.17
Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme- Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar, Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher). - Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) + waf_configs.app_profiles. - Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate). - Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei Profil-Edit. - UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im Domain-Drawer. FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents — selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt damit erstmals tatsächlich. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,11 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) {
|
||||
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.
|
||||
@@ -86,6 +91,7 @@ type upsertBody struct {
|
||||
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"`
|
||||
@@ -118,6 +124,18 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
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 {
|
||||
@@ -158,6 +176,7 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
ParanoiaLevel: body.ParanoiaLevel,
|
||||
RuleExclusions: body.RuleExclusions,
|
||||
CRSPlugins: body.CRSPlugins,
|
||||
AppProfiles: body.AppProfiles,
|
||||
ExclusionNotes: body.ExclusionNotes,
|
||||
TrustedProxies: body.TrustedProxies,
|
||||
CustomRules: body.CustomRules,
|
||||
@@ -225,6 +244,119 @@ func (h *WafHandler) PurgeAlerts(c *gin.Context) {
|
||||
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 {
|
||||
@@ -234,6 +366,8 @@ func defaultConfig(domainID int64) models.WafConfig {
|
||||
Mode: "detection",
|
||||
ParanoiaLevel: 1,
|
||||
RuleExclusions: []string{},
|
||||
CRSPlugins: []string{},
|
||||
AppProfiles: []string{},
|
||||
ExclusionNotes: map[string]string{},
|
||||
TrustedProxies: []string{},
|
||||
CustomRules: "",
|
||||
|
||||
Reference in New Issue
Block a user