package waf import ( "context" "log/slog" "net" "net/http" "strings" "github.com/corazawaf/coraza/v3/types" "github.com/dropmorepackets/haproxy-go/pkg/encoding" "github.com/dropmorepackets/haproxy-go/spop" ) // SPOEAgent wraps the haproxy-go SPOE server and dispatches each // inspected request to the appropriate per-domain Coraza engine. type SPOEAgent struct { Manager *Manager AlertWriter *AlertWriter Addr string } // ListenAndServe starts the SPOE agent. Blocks until ctx is canceled. func (a *SPOEAgent) ListenAndServe(ctx context.Context) error { agent := spop.Agent{ Addr: a.Addr, Handler: spop.HandlerFunc(a.handle), BaseContext: ctx, } return agent.ListenAndServe() } // handle is called by the haproxy-go SPOE library for every NOTIFY // frame HAProxy sends. It extracts the request data, runs Coraza, // and optionally sets a txn.waf.status variable to trigger a deny ACL. func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) { var ( clientIP string method string uri string // full request URI (path + optional ?query) httpVer string host string rawHdrs string body []byte // gepufferter Request-Body (via HAProxy option http-buffer-request) ) // Iterate over the key-value pairs HAProxy sent with this message. entry := encoding.AcquireKVEntry() defer encoding.ReleaseKVEntry(entry) for m.KV.Next(entry) { switch { case entry.NameEquals("src"): addr := entry.ValueAddr() if addr.IsValid() { clientIP = addr.String() } case entry.NameEquals("method"): method = string(entry.ValueBytes()) case entry.NameEquals("uri"): uri = string(entry.ValueBytes()) case entry.NameEquals("ver"): httpVer = string(entry.ValueBytes()) case entry.NameEquals("host"): host = string(entry.ValueBytes()) case entry.NameEquals("headers"): rawHdrs = string(entry.ValueBytes()) case entry.NameEquals("body"): // Kopieren: entry wird nach Reset() wiederverwendet, der // zugrundeliegende Puffer darf nicht referenziert bleiben. if b := entry.ValueBytes(); len(b) > 0 { body = append([]byte(nil), b...) } } entry.Reset() } if host == "" { return } de, ok := a.Manager.GetForHost(host) if !ok { 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() if err := tx.Close(); err != nil { slog.Warn("waf: tx.Close", "error", err) } }() // Feed connection metadata. if clientIP != "" { tx.ProcessConnection(clientIP, 0, "", 0) } if uri == "" { uri = "/" } if httpVer == "" { httpVer = "HTTP/1.1" } tx.ProcessURI(uri, method, httpVer) // Feed Host header first (required by many CRS rules). tx.AddRequestHeader("Host", host) // Parse and feed all raw headers. parseHeaders(rawHdrs, func(name, val string) { if !strings.EqualFold(name, "host") { // already added above tx.AddRequestHeader(name, val) } }) // Evaluate request headers. interruption := tx.ProcessRequestHeaders() // Request-Body inspizieren (POST/PUT-Payloads: Form-SQLi, JSON-Injection, // Uploads). Nur wenn die Header-Phase noch nicht geblockt hat. HAProxy // liefert den Body via `option http-buffer-request` (bis tune.bufsize) — // größere Bodies werden zur Prüfung gekappt. Content-Type kam bereits // über die Header, sodass Coraza urlencoded/multipart/json korrekt parst. if interruption == nil { if len(body) > 0 { if it, _, err := tx.WriteRequestBody(body); err != nil { slog.Warn("waf: WriteRequestBody", "error", err) } else if it != nil { interruption = it } } // ProcessRequestBody MUSS immer laufen — auch ohne Body. In Coraza wird // die GESAMTE Phase 2 (SQLi 942xxx, XSS 941xxx, die den Query-String/ARGS // prüfen) erst hier ausgewertet. Wurde das an len(body)>0 gekoppelt, // blieben GET-Requests ohne Body von allen Phase-2-Regeln ungeprüft → // Query-String-Angriffe (?id=1' OR 1=1, ?x=