Frontend Labs
⋯

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.


Generated from week10.md · 8 Oct 2026 16:36