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>
376 lines
12 KiB
Go
376 lines
12 KiB
Go
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: "",
|
||
}
|
||
}
|