fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
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>
This commit is contained in:
@@ -66,14 +66,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
|
||||
for _, ip := range cfg.TrustedProxies {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
|
||||
_ = ip // used in custom rules below if needed
|
||||
}
|
||||
}
|
||||
// Trusted proxies are NOT a SecLang directive — they are applied in the
|
||||
// SPOE agent (spoe.go): when the connection source is a trusted proxy,
|
||||
// the real client IP is taken from X-Forwarded-For before Coraza sees
|
||||
// it. (Previously this loop emitted a bogus, unrelated directive.)
|
||||
|
||||
// Custom rules (appended last so they can override CRS).
|
||||
if strings.TrimSpace(cfg.CustomRules) != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package waf
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
|
||||
// DomainEngine bundles a Coraza WAF with its operating mode.
|
||||
type DomainEngine struct {
|
||||
WAF coraza.WAF
|
||||
Mode string // "detection" | "blocking"
|
||||
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
|
||||
@@ -90,7 +92,7 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
||||
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}
|
||||
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,
|
||||
@@ -110,8 +112,9 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
||||
// (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").
|
||||
if i := lastColon(host); i >= 0 {
|
||||
host = host[:i]
|
||||
// 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]
|
||||
@@ -122,36 +125,3 @@ func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
||||
return de, true
|
||||
}
|
||||
|
||||
// lastColon returns the index of the last ':' in s that looks like a
|
||||
// port separator (after the final ']' for IPv6), or -1.
|
||||
func lastColon(s string) int {
|
||||
// IPv6 addresses in brackets: "[::1]:443"
|
||||
if len(s) > 0 && s[0] == '[' {
|
||||
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
|
||||
return rb + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// Plain host — only strip port if there's exactly one colon.
|
||||
count := 0
|
||||
idx := -1
|
||||
for i, c := range s {
|
||||
if c == ':' {
|
||||
count++
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastByte(s string, b byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package waf
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -75,6 +76,15 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
||||
return // WAF not configured or disabled for this domain
|
||||
}
|
||||
|
||||
// Trusted-Proxy-Handling: stammt die Verbindung von einem konfigurierten
|
||||
// Trusted-Proxy, ist die echte Client-IP das letzte X-Forwarded-For-Glied
|
||||
// (das der Proxy angehängt hat), nicht die Proxy-IP selbst.
|
||||
if clientIP != "" && len(de.TrustedProxies) > 0 && ipMatchesAny(clientIP, de.TrustedProxies) {
|
||||
if real := rightmostXFF(rawHdrs); real != "" {
|
||||
clientIP = real
|
||||
}
|
||||
}
|
||||
|
||||
tx := de.WAF.NewTransaction()
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
@@ -165,6 +175,53 @@ func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.Match
|
||||
})
|
||||
}
|
||||
|
||||
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
|
||||
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
|
||||
func rightmostXFF(rawHdrs string) string {
|
||||
var val string
|
||||
for _, line := range strings.Split(rawHdrs, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
|
||||
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
|
||||
}
|
||||
}
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(val, ",")
|
||||
cand := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(cand) == nil {
|
||||
return ""
|
||||
}
|
||||
return cand
|
||||
}
|
||||
|
||||
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
|
||||
func ipMatchesAny(ip string, list []string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range list {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(e, "/") {
|
||||
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
||||
// calls fn for each valid header line.
|
||||
func parseHeaders(raw string, fn func(name, val string)) {
|
||||
|
||||
37
internal/waf/spoe_test.go
Normal file
37
internal/waf/spoe_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package waf
|
||||
|
||||
import "testing"
|
||||
|
||||
// Beweist Fix #2: Trusted-Proxy-XFF-Auflösung.
|
||||
func TestRightmostXFF(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"X-Forwarded-For: 203.0.113.7": "203.0.113.7",
|
||||
"X-Forwarded-For: 203.0.113.7, 10.0.0.1": "10.0.0.1", // rightmost
|
||||
"x-forwarded-for: 1.2.3.4 , 5.6.7.8": "5.6.7.8",
|
||||
"Host: x\r\nX-Forwarded-For: 2001:db8::1": "2001:db8::1",
|
||||
"X-Forwarded-For: not-an-ip": "",
|
||||
"User-Agent: foo": "",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := rightmostXFF(raw); got != want {
|
||||
t.Errorf("rightmostXFF(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPMatchesAny(t *testing.T) {
|
||||
list := []string{"10.0.0.5", "192.168.0.0/16", "2001:db8::/32"}
|
||||
yes := []string{"10.0.0.5", "192.168.4.7", "2001:db8::abcd"}
|
||||
no := []string{"10.0.0.6", "172.16.0.1", "2002::1", "garbage"}
|
||||
for _, ip := range yes {
|
||||
if !ipMatchesAny(ip, list) {
|
||||
t.Errorf("ipMatchesAny(%q) = false, want true", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range no {
|
||||
if ipMatchesAny(ip, list) {
|
||||
t.Errorf("ipMatchesAny(%q) = true, want false", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user