feat(waf): Request-Body-Inspektion — Coraza sieht jetzt POST/PUT-Payloads — v1.3.6

Bisher inspizierte die WAF nur URL/Querystring + Header (SPOE sendete keinen
Body, ProcessRequestBody wurde nie aufgerufen) → blind für POST/PUT-Payloads
(Form-SQLi, JSON-Injection, Uploads). Jetzt:

- haproxy.cfg.tpl: `option http-buffer-request` im public_https-Frontend, NUR
  wenn WAF aktiv (.WAFEnabled) — kein RAM-pro-Connection-Overhead sonst.
- spoeCfg (haproxy.go): SPOE-Message sendet `body=req.body` an den Agent.
- spoe.go: Body einsammeln → tx.WriteRequestBody + tx.ProcessRequestBody nach
  der Header-Phase (vor MatchedRules-Log, damit Body-Treffer geloggt werden);
  Interruption blockt in blocking-Mode.

Coraza-Engine war schon bereit (SecRequestBodyAccess On + Limits, engine.go).
Puffer bis tune.bufsize (~16KB); größere Bodies zur Prüfung gekappt.
Render-Test: http-buffer-request nur bei WAF + vor dem SPOE-Filter; body=req.body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-07-31 12:27:41 +02:00
parent dca40761a4
commit 96290253c8
5 changed files with 67 additions and 2 deletions

View File

@@ -1 +1 @@
1.3.5 1.3.6

View File

@@ -81,6 +81,13 @@ frontend public_https
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3 bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- end}} {{- end}}
{{- if .WAFEnabled}} {{- 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. # WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
# filter muss vor allen http-request/http-response-Direktiven stehen. # filter muss vor allen http-request/http-response-Direktiven stehen.
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg

View File

@@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent
use-backend spoe-edgeguard-waf use-backend spoe-edgeguard-waf
spoe-message edgeguard-waf-req 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 event on-frontend-http-request
` `

View File

@@ -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) { func TestRender_MultiServerPool(t *testing.T) {
v := View{ v := View{
Backends: []BackendView{ Backends: []BackendView{

View File

@@ -41,6 +41,7 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
httpVer string httpVer string
host string host string
rawHdrs 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. // 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()) host = string(entry.ValueBytes())
case entry.NameEquals("headers"): case entry.NameEquals("headers"):
rawHdrs = string(entry.ValueBytes()) 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() entry.Reset()
} }
@@ -119,6 +126,26 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
// Evaluate request headers. // Evaluate request headers.
interruption := tx.ProcessRequestHeaders() 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). // Log all matched rules (detection + blocking).
for _, mr := range tx.MatchedRules() { for _, mr := range tx.MatchedRules() {
a.sendAlert(host, clientIP, method, uri, mr, interruption != nil) a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)