Files
edgeguard-native/internal/waf/spoe.go
Debian bd32bc343a feat(waf): Phase 2 — edgeguard-waf Binary + SPOE + Coraza Engine — v1.2.67
- cmd/edgeguard-waf/: neues Binary — lädt WAF-Configs aus DB, startet
  SPOE-Agent auf 127.0.0.1:9000, refreshed Configs alle 30s
- internal/waf/engine.go: BuildEngine() — Coraza WAF aus WafConfig bauen
  (SecLang-Direktiven: RuleEngine, PL, CRS-Include, Exclusions, Custom)
- internal/waf/manager.go: Manager — per-Hostname Coraza-Engine-Cache
  (thread-safe, Lazy-Init via Reload(), Port-Strip, IPv6-Brackets)
- internal/waf/spoe.go: SPOEAgent — haproxy-go SPOE-Handler
  (src/method/path/query/ver/host/headers aus HAProxy-Vars,
   Coraza-Transaction, Blocking: txn.waf.status=403 setzen)
- services/waf/waf.go: ListAllWithDomain() — JOIN domains+waf_configs
- go.mod: coraza/v3 v3.7.0 + dropmorepackets/haproxy-go v0.0.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:43:15 +02:00

162 lines
3.8 KiB
Go

package waf
import (
"context"
"log/slog"
"net/http"
"strings"
"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
Addr string
}
// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled.
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
path string
query string
httpVer string
host string
rawHdrs string
)
// 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("path"):
path = string(entry.ValueBytes())
case entry.NameEquals("query"):
query = 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())
}
entry.Reset()
}
if host == "" {
return
}
de, ok := a.Manager.GetForHost(host)
if !ok {
return // WAF not configured or disabled for this domain
}
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)
}
// Build full URI.
uri := path
if query != "" {
uri += "?" + query
}
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()
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,
"mode", de.Mode,
)
if de.Mode == "blocking" {
if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil {
slog.Warn("waf: SetInt64 status", "error", err)
}
}
return
}
// Alert-only log for detection mode.
if tx.IsInterrupted() && de.Mode != "blocking" {
slog.Info("waf: request flagged (detection)",
"host", host, "method", method, "uri", uri, "client", clientIP,
)
}
}
// 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)
}
}
}