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>
271 lines
7.4 KiB
Go
271 lines
7.4 KiB
Go
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 && 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)
|
||
}
|
||
|
||
if interruption != nil {
|
||
status := interruption.Status
|
||
if status == 0 {
|
||
status = http.StatusForbidden
|
||
}
|
||
slog.Info("waf: request blocked",
|
||
"host", host, "method", method, "uri", uri,
|
||
"client", clientIP, "status", status, "rule", interruption.RuleID,
|
||
)
|
||
if de.Mode == "blocking" {
|
||
if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil {
|
||
slog.Warn("waf: SetInt64 status", "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// sendAlert enqueues a WAF alert for async DB write.
|
||
// Control-flow rules (pass+nolog with empty message) are skipped —
|
||
// they are CRS paranoia-level skip-markers, not real detections.
|
||
func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.MatchedRule, blocked bool) {
|
||
if a.AlertWriter == nil {
|
||
return
|
||
}
|
||
ruleID := mr.Rule().ID()
|
||
// Skip CRS setup/initialization rules (900xxx–909xxx) — they fire on
|
||
// every request as part of CRS init and are not security events.
|
||
// Real detection rules start at 910xxx (IP reputation) and above.
|
||
if ruleID > 0 && ruleID < 910000 {
|
||
return
|
||
}
|
||
// Skip control-flow rules with no message (PL-skip markers).
|
||
if mr.Message() == "" {
|
||
return
|
||
}
|
||
action := "detected"
|
||
if blocked && mr.Disruptive() {
|
||
action = "blocked"
|
||
}
|
||
a.AlertWriter.Send(Alert{
|
||
Hostname: host,
|
||
ClientIP: clientIP,
|
||
Method: method,
|
||
URI: uri,
|
||
RuleID: mr.Rule().ID(),
|
||
RuleMsg: mr.Message(),
|
||
Severity: mr.Rule().Severity().String(),
|
||
Action: action,
|
||
})
|
||
}
|
||
|
||
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
|
||
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
|
||
func rightmostXFF(rawHdrs string) string {
|
||
var val string
|
||
for _, line := range strings.Split(rawHdrs, "\n") {
|
||
line = strings.TrimRight(line, "\r")
|
||
idx := strings.IndexByte(line, ':')
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
|
||
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
|
||
}
|
||
}
|
||
if val == "" {
|
||
return ""
|
||
}
|
||
parts := strings.Split(val, ",")
|
||
cand := strings.TrimSpace(parts[len(parts)-1])
|
||
if net.ParseIP(cand) == nil {
|
||
return ""
|
||
}
|
||
return cand
|
||
}
|
||
|
||
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
|
||
func ipMatchesAny(ip string, list []string) bool {
|
||
parsed := net.ParseIP(ip)
|
||
if parsed == nil {
|
||
return false
|
||
}
|
||
for _, e := range list {
|
||
e = strings.TrimSpace(e)
|
||
if e == "" {
|
||
continue
|
||
}
|
||
if strings.Contains(e, "/") {
|
||
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
|
||
return true
|
||
}
|
||
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
||
// calls fn for each valid header line.
|
||
func parseHeaders(raw string, fn func(name, val string)) {
|
||
for _, line := range strings.Split(raw, "\n") {
|
||
line = strings.TrimRight(line, "\r")
|
||
if line == "" {
|
||
continue
|
||
}
|
||
idx := strings.IndexByte(line, ':')
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(line[:idx])
|
||
val := strings.TrimSpace(line[idx+1:])
|
||
if name != "" {
|
||
fn(name, val)
|
||
}
|
||
}
|
||
}
|