Files
edgeguard-native/internal/handlers/waf.go
Debian 37f729381d feat(waf): CRS-App-Exclusion-Plugins (Nextcloud/WordPress/Drupal) pro Domain — v1.3.12
Statt manueller SecRuleRemoveById-IDs kann man pro Domain offizielle OWASP-CRS-
Exclusion-Plugins aktivieren — pfad-genaue, upstream-gepflegte App-Ausnahmen.

- Migration 0046: waf_configs.crs_plugins text[].
- Engine (engine.go): je gewähltem Plugin werden config/before VOR den CRS-Rules
  und after DANACH inkludiert (exakt nach OWASP-CRS-Plugin-Spec); nur die für
  DIESE Domain gewählten, nur wenn die Datei existiert. Whitelist KnownCRSPlugins.
- Handler: crs_plugins im Upsert-Body + Whitelist-Validierung (Include-Pfad-
  Injection-Schutz).
- Packaging (postinst): lädt die Plugins (coreruleset/<name>-plugin) nach
  <crs>/plugins/ — self-healing auf jedem configure, nur fehlende.
- UI: Multi-Select „App-Profile (CRS-Plugins)" im WAF-Config-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 10:28:03 +02:00

242 lines
7.2 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)
}
// 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"`
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{}
}
// 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,
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: "",
}
}