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:
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal file
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
60
internal/waf/engine_plugins_test.go
Normal file
60
internal/waf/engine_plugins_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user