Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
3.4 KiB
Go
128 lines
3.4 KiB
Go
package waf
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"sync"
|
|
|
|
"github.com/corazawaf/coraza/v3"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
// DomainEngine bundles a Coraza WAF with its operating mode.
|
|
type DomainEngine struct {
|
|
WAF coraza.WAF
|
|
Mode string // "detection" | "blocking"
|
|
TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For
|
|
}
|
|
|
|
// Manager holds per-domain Coraza engine instances. Engines are
|
|
// rebuilt only when their configuration changes (UpdatedAt differs).
|
|
// All public methods are safe for concurrent use.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
engines map[string]*DomainEngine // hostname → engine (nil entry = disabled)
|
|
configKeys map[string]configKey // hostname → last-seen config fingerprint
|
|
crsDir string
|
|
}
|
|
|
|
// configKey identifies a specific WAF config snapshot so we only
|
|
// rebuild the engine when something actually changed.
|
|
type configKey struct {
|
|
enabled bool
|
|
mode string
|
|
paranoiaLevel int
|
|
updatedAt int64 // unix nano
|
|
}
|
|
|
|
// NewManager creates an empty Manager with the given CRS directory.
|
|
func NewManager(crsDir string) *Manager {
|
|
if crsDir == "" {
|
|
crsDir = DefaultCRSDir
|
|
}
|
|
return &Manager{
|
|
engines: make(map[string]*DomainEngine),
|
|
configKeys: make(map[string]configKey),
|
|
crsDir: crsDir,
|
|
}
|
|
}
|
|
|
|
// DomainConfig pairs a domain hostname with its WAF policy.
|
|
type DomainConfig struct {
|
|
Hostname string
|
|
Config models.WafConfig
|
|
}
|
|
|
|
// Reload refreshes engines from the given list, rebuilding only when
|
|
// the config has actually changed since the last call.
|
|
func (m *Manager) Reload(domains []DomainConfig) error {
|
|
m.mu.RLock()
|
|
prevEngines := m.engines
|
|
prevKeys := m.configKeys
|
|
m.mu.RUnlock()
|
|
|
|
newEngines := make(map[string]*DomainEngine, len(domains))
|
|
newKeys := make(map[string]configKey, len(domains))
|
|
|
|
for _, dc := range domains {
|
|
ck := configKey{
|
|
enabled: dc.Config.Enabled,
|
|
mode: dc.Config.Mode,
|
|
paranoiaLevel: dc.Config.ParanoiaLevel,
|
|
updatedAt: dc.Config.UpdatedAt.UnixNano(),
|
|
}
|
|
newKeys[dc.Hostname] = ck
|
|
|
|
if !dc.Config.Enabled {
|
|
newEngines[dc.Hostname] = nil
|
|
continue
|
|
}
|
|
|
|
// Reuse existing engine if config hasn't changed.
|
|
if prev, ok := prevKeys[dc.Hostname]; ok && prev == ck {
|
|
if existing := prevEngines[dc.Hostname]; existing != nil {
|
|
newEngines[dc.Hostname] = existing
|
|
continue
|
|
}
|
|
}
|
|
|
|
waf, err := BuildEngine(dc.Config, m.crsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
|
|
}
|
|
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies}
|
|
slog.Info("waf: engine (re)loaded",
|
|
"host", dc.Hostname,
|
|
"mode", dc.Config.Mode,
|
|
"paranoia_level", dc.Config.ParanoiaLevel,
|
|
"crs", crsAvailable(m.crsDir),
|
|
)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.engines = newEngines
|
|
m.configKeys = newKeys
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// GetForHost returns the DomainEngine for the given hostname, or
|
|
// (nil, false) when the domain has no WAF or WAF is disabled.
|
|
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
|
// Strip port if present (e.g. "example.com:443" → "example.com").
|
|
// SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is.
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
m.mu.RLock()
|
|
de, ok := m.engines[host]
|
|
m.mu.RUnlock()
|
|
if !ok || de == nil {
|
|
return nil, false
|
|
}
|
|
return de, true
|
|
}
|
|
|