diff --git a/VERSION b/VERSION index aaf8be7..6f96ed0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.5 \ No newline at end of file +1.3.6 \ No newline at end of file diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index 92669a6..913dd40 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -81,6 +81,13 @@ frontend public_https bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3 {{- end}} {{- if .WAFEnabled}} + # WAF: Request-Body puffern, damit edgeguard-waf den Body inspizieren + # kann (POST/PUT-Payloads: Form-SQLi, JSON-Injection, Datei-Uploads). + # Bewusst NUR wenn mind. eine Domain WAF nutzt (.WAFEnabled) — sonst + # kein RAM-pro-Connection-Overhead (vgl. Kommentar am Body-Size-Cap). + # Puffer bis tune.bufsize (~16KB); größere Bodies werden zur Inspektion + # gekappt — typische Injection-Payloads sind klein. + option http-buffer-request # WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request. # filter muss vor allen http-request/http-response-Direktiven stehen. filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index dfbb10d..dc36ecd 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent use-backend spoe-edgeguard-waf spoe-message edgeguard-waf-req - args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) + args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) body=req.body event on-frontend-http-request ` diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index b0b7830..e8d4d19 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -592,6 +592,37 @@ func TestRender_ServerTimeoutOverride(t *testing.T) { } } +func TestRender_WAFBuffersRequestBody(t *testing.T) { + // Ohne WAF: kein Body-Buffering (kein RAM-Overhead). + off := renderView(t, View{WAFEnabled: false}) + if strings.Contains(off, "option http-buffer-request") { + t.Errorf("ohne WAF darf kein `option http-buffer-request` gerendert werden:\n%s", off) + } + if strings.Contains(off, "filter spoe") { + t.Errorf("ohne WAF darf kein SPOE-Filter gerendert werden") + } + + // Mit WAF: Body-Buffering VOR dem SPOE-Filter, damit req.body verfügbar ist. + on := renderView(t, View{WAFEnabled: true}) + idxBuf := strings.Index(on, "option http-buffer-request") + idxFilter := strings.Index(on, "filter spoe engine edgeguard-waf") + if idxBuf < 0 { + t.Fatalf("mit WAF muss `option http-buffer-request` gerendert werden:\n%s", on) + } + if idxFilter < 0 { + t.Fatalf("mit WAF muss der SPOE-Filter gerendert werden") + } + if idxBuf > idxFilter { + t.Errorf("`option http-buffer-request` muss VOR dem SPOE-Filter stehen (buf=%d filter=%d)", idxBuf, idxFilter) + } + + // Die SPOE-Message muss den Body an den Agent schicken, sonst kann + // Coraza ihn nicht inspizieren. + if !strings.Contains(spoeCfg, "body=req.body") { + t.Errorf("spoeCfg muss `body=req.body` an den WAF-Agent senden:\n%s", spoeCfg) + } +} + func TestRender_MultiServerPool(t *testing.T) { v := View{ Backends: []BackendView{ diff --git a/internal/waf/spoe.go b/internal/waf/spoe.go index b583103..2f9ffc0 100644 --- a/internal/waf/spoe.go +++ b/internal/waf/spoe.go @@ -41,6 +41,7 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc 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. @@ -63,6 +64,12 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc 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() } @@ -119,6 +126,26 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc // 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 && len(body) > 0 { + if it, _, err := tx.WriteRequestBody(body); err != nil { + slog.Warn("waf: WriteRequestBody", "error", err) + } else if it != nil { + interruption = it + } else { + it, err := tx.ProcessRequestBody() + if err != nil { + slog.Warn("waf: ProcessRequestBody", "error", err) + } else if it != nil { + interruption = it + } + } + } + // Log all matched rules (detection + blocking). for _, mr := range tx.MatchedRules() { a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)