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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user