diff --git a/VERSION b/VERSION index 7169d35..ba50e86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.11 \ No newline at end of file +1.3.12 \ No newline at end of file diff --git a/internal/database/migrations/0046_waf_crs_plugins.sql b/internal/database/migrations/0046_waf_crs_plugins.sql new file mode 100644 index 0000000..bec5352 --- /dev/null +++ b/internal/database/migrations/0046_waf_crs_plugins.sql @@ -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 +-- /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 diff --git a/internal/handlers/waf.go b/internal/handlers/waf.go index 8e2f97c..21cbee7 100644 --- a/internal/handlers/waf.go +++ b/internal/handlers/waf.go @@ -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, diff --git a/internal/models/waf.go b/internal/models/waf.go index 918d9c0..7776d2d 100644 --- a/internal/models/waf.go +++ b/internal/models/waf.go @@ -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"` // 1–4 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 /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"` diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go index e39d004..17232e6 100644 --- a/internal/services/waf/waf.go +++ b/internal/services/waf/waf.go @@ -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) } diff --git a/internal/waf/engine.go b/internal/waf/engine.go index 305925a..214770a 100644 --- a/internal/waf/engine.go +++ b/internal/waf/engine.go @@ -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 +// /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": diff --git a/internal/waf/engine_plugins_test.go b/internal/waf/engine_plugins_test.go new file mode 100644 index 0000000..7d1dda6 --- /dev/null +++ b/internal/waf/engine_plugins_test.go @@ -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) + } +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index eeffc42..e2317a1 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -1934,6 +1934,9 @@ "enabled": "Aktiviert", "mode": "Modus", "paranoia": "Paranoia-Level", + "crsPlugins": "App-Profile (CRS-Plugins)", + "crsPluginsHint": "Offizielle OWASP-CRS-Exclusion-Plugins für bekannte Apps — deaktivieren automatisch die typischen False-Positive-Regeln pfad-genau (z.B. Nextcloud-WebDAV, WordPress-Editor). Sauberer als manuelle Regel-IDs.", + "crsPluginsPlaceholder": "App-Profile wählen (optional)", "exclusions": "Regel-Ausnahmen", "exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).", "trustedProxies": "Vertrauenswürdige Proxys", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 3827e1f..b89f025 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -1934,6 +1934,9 @@ "enabled": "Enabled", "mode": "Mode", "paranoia": "Paranoia Level", + "crsPlugins": "App profiles (CRS plugins)", + "crsPluginsHint": "Official OWASP CRS exclusion plugins for well-known apps — automatically disable the typical false-positive rules in a path-scoped way (e.g. Nextcloud WebDAV, WordPress editor). Cleaner than manual rule IDs.", + "crsPluginsPlaceholder": "Select app profiles (optional)", "exclusions": "Rule Exclusions", "exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).", "trustedProxies": "Trusted Proxies", diff --git a/management-ui/src/pages/WAF/index.tsx b/management-ui/src/pages/WAF/index.tsx index 4e091ca..5689aa7 100644 --- a/management-ui/src/pages/WAF/index.tsx +++ b/management-ui/src/pages/WAF/index.tsx @@ -33,11 +33,19 @@ interface WafConfig { mode: 'detection' | 'blocking' paranoia_level: number rule_exclusions: string[] + crs_plugins: string[] exclusion_notes: Record trusted_proxies: string[] custom_rules: string } +// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen. +const CRS_PLUGIN_OPTIONS = [ + { value: 'nextcloud', label: 'Nextcloud' }, + { value: 'wordpress', label: 'WordPress' }, + { value: 'drupal', label: 'Drupal' }, +] + // ---------- API helpers ----------------------------------------------------- async function fetchDomains(): Promise { @@ -65,6 +73,7 @@ function defaultConfig(domainId: number): WafConfig { mode: 'detection', paranoia_level: 1, rule_exclusions: [], + crs_plugins: [], exclusion_notes: {}, trusted_proxies: [], custom_rules: '', @@ -75,6 +84,7 @@ interface WafFormValues { enabled: boolean mode: 'detection' | 'blocking' paranoia_level: number + crs_plugins: string[] trusted_proxies_str: string custom_rules: string } @@ -147,6 +157,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { mode: vals.mode, paranoia_level: vals.paranoia_level, rule_exclusions: cfg?.rule_exclusions ?? [], + crs_plugins: vals.crs_plugins ?? [], exclusion_notes: cfg?.exclusion_notes ?? {}, trusted_proxies: proxies, custom_rules: vals.custom_rules ?? '', @@ -190,6 +201,20 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { + +