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>
This commit is contained in:
Debian
2026-08-03 10:28:03 +02:00
parent 088910ee19
commit 37f729381d
11 changed files with 220 additions and 19 deletions

View File

@@ -0,0 +1,19 @@
-- +goose Up
-- +goose StatementBegin
-- CRS-App-Exclusion-Plugins pro Domain (OWASP-CRS-Plugin-System). Liste von
-- Plugin-Namen (z. B. 'nextcloud','wordpress','drupal'). Der WAF-Renderer
-- inkludiert je gewähltem Plugin dessen config/before/after-Dateien aus
-- <crsDir>/plugins/ an den korrekten Punkten (config+before VOR den CRS-Rules,
-- after DANACH) → pfad-genaue, upstream-gepflegte App-Ausnahmen statt manueller
-- SecRuleRemoveById-IDs. waf_configs ist repliziert; der Renderer läuft pro
-- Node lokal, daher kein Cross-Node-Effekt außer der Config selbst.
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS crs_plugins TEXT[] NOT NULL DEFAULT '{}';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE waf_configs DROP COLUMN IF EXISTS crs_plugins;
-- +goose StatementEnd

View File

@@ -16,6 +16,7 @@ import (
"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" /
@@ -80,13 +81,14 @@ func (h *WafHandler) Get(c *gin.Context) {
// 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"`
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.
@@ -110,9 +112,20 @@ func (h *WafHandler) Upsert(c *gin.Context) {
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{}
@@ -144,6 +157,7 @@ func (h *WafHandler) Upsert(c *gin.Context) {
Mode: body.Mode,
ParanoiaLevel: body.ParanoiaLevel,
RuleExclusions: body.RuleExclusions,
CRSPlugins: body.CRSPlugins,
ExclusionNotes: body.ExclusionNotes,
TrustedProxies: body.TrustedProxies,
CustomRules: body.CustomRules,

View File

@@ -11,6 +11,10 @@ type WafConfig struct {
Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking"
ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 14
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
// CRSPlugins: aktivierte OWASP-CRS-App-Exclusion-Plugins (z. B.
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
// config/before/after-Dateien aus <crsDir>/plugins/.
CRSPlugins []string `gorm:"column:crs_plugins;type:text[]" json:"crs_plugins"`
ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`

View File

@@ -22,7 +22,7 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
rule_exclusions, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at
FROM waf_configs
`
@@ -30,7 +30,7 @@ func scan(row pgx.Row) (*models.WafConfig, error) {
var c models.WafConfig
err := row.Scan(
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
&c.RuleExclusions, &c.CRSPlugins, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
)
if err != nil {
return nil, err
@@ -82,22 +82,23 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi
row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_configs
(domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
rule_exclusions, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions,
crs_plugins = EXCLUDED.crs_plugins,
exclusion_notes = EXCLUDED.exclusion_notes,
trusted_proxies = EXCLUDED.trusted_proxies,
custom_rules = EXCLUDED.custom_rules,
updated_at = EXCLUDED.updated_at
RETURNING id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
rule_exclusions, crs_plugins, exclusion_notes, trusted_proxies, custom_rules, updated_at
`,
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
c.RuleExclusions, c.CRSPlugins, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
)
return scan(row)
}

View File

@@ -48,12 +48,23 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
pl = 1
}
fmt.Fprintf(&sb, "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl)
setupConf := filepath.Join(crsDir, "crs-setup.conf")
if _, err := os.Stat(setupConf); err == nil {
fmt.Fprintf(&sb, "Include %s\n", setupConf)
includeIfExists(&sb, filepath.Join(crsDir, "crs-setup.conf"))
// CRS-App-Exclusion-Plugins: config + before laufen VOR den CRS-Rules
// (setzen Enable-Vars + pfad-scoped ctl:ruleRemoveById), after DANACH —
// exakt nach OWASP-CRS-Plugin-Spec. Es werden NUR die für DIESE Domain
// gewählten Plugins inkludiert (per-Domain, nicht global).
plugins := resolveCRSPlugins(cfg.CRSPlugins)
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-config.conf"))
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-before.conf"))
}
// rules/*.conf ist ein Glob (kein Stat) — crsAvailable() hat oben bereits
// bestätigt, dass mind. eine .conf existiert.
fmt.Fprintf(&sb, "Include %s\n", filepath.Join(crsDir, "rules", "*.conf"))
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-after.conf"))
}
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
fmt.Fprintf(&sb, "Include %s\n", rulesGlob)
}
// Rule exclusions (applied after CRS load so they override CRS).
@@ -78,6 +89,35 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
return sb.String()
}
// KnownCRSPlugins mappt den kurzen Plugin-Namen (gespeichert in
// waf_configs.crs_plugins, im UI gewählt) auf sein Datei-Prefix in
// <crsDir>/plugins/. Nur diese werden paketiert (postinst) und akzeptiert.
var KnownCRSPlugins = map[string]string{
"nextcloud": "nextcloud-rule-exclusions",
"wordpress": "wordpress-rule-exclusions",
"drupal": "drupal-rule-exclusions",
}
// resolveCRSPlugins mappt gewählte Plugin-Namen auf ihre Datei-Prefixe und
// filtert unbekannte/leere raus — defensiv, nie ungültige Includes rendern.
func resolveCRSPlugins(names []string) []string {
out := make([]string, 0, len(names))
for _, n := range names {
if prefix, ok := KnownCRSPlugins[strings.TrimSpace(n)]; ok {
out = append(out, prefix)
}
}
return out
}
// includeIfExists rendert eine Include-Zeile nur, wenn die Datei existiert — so
// bricht ein gewähltes-aber-nicht-installiertes Plugin die Config nicht.
func includeIfExists(sb *strings.Builder, path string) {
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(sb, "Include %s\n", path)
}
}
func ruleEngineMode(mode string) string {
switch mode {
case "blocking":

View File

@@ -0,0 +1,60 @@
package waf
import (
"os"
"path/filepath"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// mustWrite legt eine Datei (inkl. Verzeichnis) an.
func mustWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestBuildDirectives_CRSPluginIncludeOrder(t *testing.T) {
crs := t.TempDir()
mustWrite(t, filepath.Join(crs, "crs-setup.conf"), "# setup\n")
mustWrite(t, filepath.Join(crs, "rules", "REQUEST-942.conf"), "# rules\n")
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-config.conf"), "# nc config\n")
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-before.conf"), "# nc before\n")
cfg := models.WafConfig{Mode: "blocking", ParanoiaLevel: 1, CRSPlugins: []string{"nextcloud", "unknown-x"}}
out := buildDirectives(cfg, crs)
iSetup := strings.Index(out, "crs-setup.conf")
iCfg := strings.Index(out, "nextcloud-rule-exclusions-config.conf")
iBefore := strings.Index(out, "nextcloud-rule-exclusions-before.conf")
iRules := strings.Index(out, filepath.Join("rules", "*.conf"))
if iSetup < 0 || iCfg < 0 || iBefore < 0 || iRules < 0 {
t.Fatalf("erwartete Includes fehlen:\n%s", out)
}
// config + before MÜSSEN vor den CRS-Rules stehen (Plugin-Spec).
if iSetup >= iCfg || iCfg >= iBefore || iBefore >= iRules {
t.Errorf("falsche Include-Reihenfolge (setup=%d cfg=%d before=%d rules=%d):\n%s", iSetup, iCfg, iBefore, iRules, out)
}
// Unbekanntes Plugin darf NICHT inkludiert werden (Whitelist).
if strings.Contains(out, "unknown-x") {
t.Errorf("unbekanntes Plugin wurde inkludiert:\n%s", out)
}
// Nicht existente after.conf → keine Include-Zeile.
if strings.Contains(out, "nextcloud-rule-exclusions-after.conf") {
t.Errorf("nicht existente after.conf wurde inkludiert:\n%s", out)
}
}
func TestResolveCRSPlugins(t *testing.T) {
got := resolveCRSPlugins([]string{"nextcloud", " wordpress ", "bogus", ""})
want := "nextcloud-rule-exclusions,wordpress-rule-exclusions"
if strings.Join(got, ",") != want {
t.Errorf("resolveCRSPlugins=%v want %q", got, want)
}
}