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