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>
This commit is contained in:
Debian
2026-06-02 15:43:15 +02:00
parent 72f793552e
commit bd32bc343a
8 changed files with 599 additions and 16 deletions

110
internal/waf/engine.go Normal file
View File

@@ -0,0 +1,110 @@
// Package waf implements the per-domain WAF engine for EdgeGuard.
// It wraps Coraza v3 (OWASP Core Rule Set) and exposes a simple
// hostname-keyed engine manager that the SPOE agent uses.
package waf
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/corazawaf/coraza/v3"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
const (
DefaultCRSDir = "/usr/share/edgeguard/waf/crs"
DefaultSPOEAddr = "127.0.0.1:9000"
)
// BuildEngine creates a Coraza WAF instance for the given domain config.
// crsDir is the path to the OWASP CRS directory (may be empty — engine
// works without CRS, using only the basic Coraza core rules).
func BuildEngine(cfg models.WafConfig, crsDir string) (coraza.WAF, error) {
directives := buildDirectives(cfg, crsDir)
wafCfg := coraza.NewWAFConfig().
WithRequestBodyAccess().
WithDirectives(directives)
return coraza.NewWAF(wafCfg)
}
// buildDirectives assembles the SecLang directives for a domain config.
func buildDirectives(cfg models.WafConfig, crsDir string) string {
var sb strings.Builder
sb.WriteString("SecRequestBodyAccess On\n")
sb.WriteString("SecResponseBodyAccess Off\n")
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode)))
if crsDir != "" && crsAvailable(crsDir) {
// Paranoia level MUST be set before CRS rules are included.
pl := cfg.ParanoiaLevel
if pl < 1 || pl > 4 {
pl = 1
}
sb.WriteString(fmt.Sprintf(
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
))
setupConf := filepath.Join(crsDir, "crs-setup.conf")
if _, err := os.Stat(setupConf); err == nil {
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf))
}
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob))
}
// Rule exclusions (applied after CRS load so they override CRS).
for _, id := range cfg.RuleExclusions {
id = strings.TrimSpace(id)
if id != "" {
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id))
}
}
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
for _, ip := range cfg.TrustedProxies {
ip = strings.TrimSpace(ip)
if ip != "" {
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
_ = ip // used in custom rules below if needed
}
}
// Custom rules (appended last so they can override CRS).
if strings.TrimSpace(cfg.CustomRules) != "" {
sb.WriteString(cfg.CustomRules)
sb.WriteString("\n")
}
return sb.String()
}
func ruleEngineMode(mode string) string {
switch mode {
case "blocking":
return "On"
default: // "detection"
return "DetectionOnly"
}
}
// crsAvailable returns true when the CRS rules directory exists and
// contains at least one .conf file.
func crsAvailable(crsDir string) bool {
rulesDir := filepath.Join(crsDir, "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return false
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".conf") {
return true
}
}
return false
}

121
internal/waf/manager.go Normal file
View File

@@ -0,0 +1,121 @@
package waf
import (
"fmt"
"log/slog"
"sync"
"github.com/corazawaf/coraza/v3"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// DomainEngine bundles a Coraza WAF with its operating mode.
type DomainEngine struct {
WAF coraza.WAF
Mode string // "detection" | "blocking"
}
// Manager holds per-domain Coraza engine instances. Engines are
// created lazily on first Reload() and cached until the next reload.
// All public methods are safe for concurrent use.
type Manager struct {
mu sync.RWMutex
engines map[string]*DomainEngine // hostname → engine (nil entry = disabled)
crsDir string
}
// NewManager creates an empty Manager with the given CRS directory.
func NewManager(crsDir string) *Manager {
if crsDir == "" {
crsDir = DefaultCRSDir
}
return &Manager{
engines: make(map[string]*DomainEngine),
crsDir: crsDir,
}
}
// DomainConfig pairs a domain hostname with its WAF policy.
type DomainConfig struct {
Hostname string
Config models.WafConfig
}
// Reload rebuilds all engine instances from the given list. Domains
// that are disabled get a nil entry so GetForHost returns quickly
// without looking up a missing key.
func (m *Manager) Reload(domains []DomainConfig) error {
engines := make(map[string]*DomainEngine, len(domains))
for _, dc := range domains {
if !dc.Config.Enabled {
engines[dc.Hostname] = nil
continue
}
waf, err := BuildEngine(dc.Config, m.crsDir)
if err != nil {
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
}
engines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
slog.Info("waf: engine loaded",
"host", dc.Hostname,
"mode", dc.Config.Mode,
"paranoia_level", dc.Config.ParanoiaLevel,
"crs", crsAvailable(m.crsDir),
)
}
m.mu.Lock()
m.engines = engines
m.mu.Unlock()
return nil
}
// GetForHost returns the DomainEngine for the given hostname, or
// (nil, false) when the domain has no WAF or WAF is disabled.
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
// Strip port if present (e.g. "example.com:443" → "example.com").
if i := lastColon(host); i >= 0 {
host = host[:i]
}
m.mu.RLock()
de, ok := m.engines[host]
m.mu.RUnlock()
if !ok || de == nil {
return nil, false
}
return de, true
}
// lastColon returns the index of the last ':' in s that looks like a
// port separator (after the final ']' for IPv6), or -1.
func lastColon(s string) int {
// IPv6 addresses in brackets: "[::1]:443"
if len(s) > 0 && s[0] == '[' {
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
return rb + 1
}
return -1
}
// Plain host — only strip port if there's exactly one colon.
count := 0
idx := -1
for i, c := range s {
if c == ':' {
count++
idx = i
}
}
if count == 1 {
return idx
}
return -1
}
func lastByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}

161
internal/waf/spoe.go Normal file
View File

@@ -0,0 +1,161 @@
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)
}
}
}