Day 74 — WAF Rule Scoring & Exceptions
Learning Objectives
- Build a scoring WAF: block only when multiple rules match (reduce false positives)
- Implement per-IP and per-path exception lists
- Build the config hot-reload used for emergency WAF disable
Exercise: Complete Scoring WAF
package waf
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"sync"
"time"
)
type WAFRuleConfig struct {
Mode string `json:"mode"` // off|detection|blocking
BlockThreshold int `json:"block_threshold"` // block if total score >= this
CategoryThresholds map[string]int `json:"category_thresholds"`
TrustedCIDRs []string `json:"trusted_cidrs"`
ExceptionPaths []string `json:"exception_paths"` // paths that bypass WAF
}
type ScoringWAF struct {
rules []Rule
configFile string
config *WAFRuleConfig
configMu sync.RWMutex
trustedNets []*net.IPNet
lastModTime time.Time
auditor AuditWriter
}
func NewScoringWAF(rules []Rule, configFile string, auditor AuditWriter) (*ScoringWAF, error) {
w := &ScoringWAF{
rules: rules,
configFile: configFile,
auditor: auditor,
}
if err := w.loadConfig(); err != nil {
return nil, err
}
go w.watchConfig()
return w, nil
}
func (w *ScoringWAF) loadConfig() error {
data, err := os.ReadFile(w.configFile)
if err != nil { return err }
var cfg WAFRuleConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("invalid WAF config: %w", err)
}
// Parse trusted CIDRs
var nets []*net.IPNet
for _, cidr := range cfg.TrustedCIDRs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("invalid CIDR %q: %w", cidr, err)
}
nets = append(nets, ipNet)
}
w.configMu.Lock()
defer w.configMu.Unlock()
w.config = &cfg
w.trustedNets = nets
fmt.Printf("[WAF] config loaded: mode=%s threshold=%d\n", cfg.Mode, cfg.BlockThreshold)
return nil
}
func (w *ScoringWAF) watchConfig() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
info, err := os.Stat(w.configFile)
if err != nil { continue }
if info.ModTime().After(w.lastModTime) {
w.lastModTime = info.ModTime()
if err := w.loadConfig(); err != nil {
fmt.Printf("[WAF] config reload error: %v\n", err)
} else {
fmt.Println("[WAF] config hot-reloaded")
}
}
}
}
func (w *ScoringWAF) isTrusted(r *http.Request) bool {
w.configMu.RLock()
defer w.configMu.RUnlock()
ip := realClientIP(r)
parsedIP := net.ParseIP(ip)
if parsedIP == nil { return false }
for _, network := range w.trustedNets {
if network.Contains(parsedIP) {
return true
}
}
return false
}
func (w *ScoringWAF) isExemptPath(path string) bool {
w.configMu.RLock()
defer w.configMu.RUnlock()
for _, exempt := range w.config.ExceptionPaths {
if strings.HasPrefix(path, exempt) {
return true
}
}
return false
}
Config file: waf-config.json
{
"mode": "blocking",
"block_threshold": 8,
"category_thresholds": {
"SQLI": 5,
"XSS": 4,
"TRAVERSAL": 1,
"CMDINJ": 1
},
"trusted_cidrs": [
"10.0.0.0/8",
"172.16.0.0/12"
],
"exception_paths": [
"/api/content-editor",
"/api/html-preview"
]
}
Real-World Context
Block threshold tuning is what WAF vendors charge professional services for. A threshold too low → high false positive rate → security team gets alert fatigue. Too high → attackers score below threshold. The exception paths for /api/content-editor is real — rich text editors legitimately send HTML that WAF rules trigger on.