Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ Designed to run as a Docker sidecar in front of your backend service (behind Ngi
- Dynamic backend discovery via DNS SRV (Consul, Nomad)
- JSON-RPC 2.0 method extraction (single + batch), deep inspection with schema discovery
- Real IP extraction from X-Real-IP / X-Forwarded-For with trusted proxy support
- Differentiated proxy error codes: `503` + `Retry-After` for circuit-breaker open / empty pool, `502` for real upstream errors, `500` for pipeline panics — all surfaced via `wafsrv_proxy_errors_total{target,reason}`

**WAF & Security**
- [Coraza](https://coraza.io/) WAF engine with OWASP CRS v4 (detection or blocking mode)
- Per-IP rate limiting (token bucket) with per-method rules and composite keys
- Per-IP rate limiting (token bucket) with per-method (RPC) and per-URL (HTTP path/method/host) rules and composite keys
- IP whitelist/blacklist (static config + runtime API)
- GeoIP country/ASN lookup — block, challenge, or log by country (free [db-ip.com](https://db-ip.com/db/lite.php) databases)
- IP reputation feeds — FireHOL blacklists, Tor exit nodes, datacenter ASN detection, custom feeds
Expand All @@ -28,6 +29,7 @@ Designed to run as a Docker sidecar in front of your backend service (behind Ngi
- Escalation: N failed challenges -> soft block with TTL
- Webhook alerting (Slack, Telegram, etc.)
- Adaptive auto Under Attack Mode (RPS spike, error rate, latency, blocked rate triggers)
- Attack-time policy expansion: `TrafficFilter.Rules[].AttackOnly` (rule fires only during attack) and `Adaptive.AutoAttack.ScoreBoost` (additive WAFScore bonus while attack is on) — keep aggressive challenges dormant in peace time

**Observability**
- Prometheus metrics (requests, latency, RPC methods, decisions, rate limits, IP blocks)
Expand Down
9 changes: 9 additions & 0 deletions cfg/local.toml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ Listen = "127.0.0.1:8081"
# ErrorRateThreshold = 20 # trigger if error % > N (0 = disabled)
# LatencyThresholdMs = 500 # trigger if avg latency > N ms (0 = disabled)
# BlockedRateThreshold = 50 # trigger if blocked % > N (0 = disabled)
# ScoreBoost = 0 # +N to WAFScore during attack mode (must be < Decision.CaptchaThreshold)
# Window = "1m"
# Cooldown = "5m"
# Duration = "10m"

# === AttackOnly TrafficFilter rule (active only during Under Attack Mode) ===
#
# [[TrafficFilter.Rules]]
# Name = "search-attack-challenge"
# AttackOnly = true # rule is dormant in peace time, fires only when attack mode is on
# Action = "captcha"
# Path = ["/search/", "/catalog/"]
6 changes: 6 additions & 0 deletions e2e/cfg/e2e.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ Name = "e2e-log-bot"
Action = "log"
UAPrefix = ["E2ELogBot/"]

[[TrafficFilter.Rules]]
Name = "e2e-attack-only-bot"
AttackOnly = true
Action = "block"
UAPrefix = ["E2EAttackBot/"]

[Signing]
Enabled = true
Mode = "detection"
Expand Down
41 changes: 41 additions & 0 deletions e2e/management_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,47 @@ func (s *E2ESuite) Test08_Attack_EnableNoDuration() {
s.mgmtRPC("attack.disable", "{}")
}

func (s *E2ESuite) Test08_Attack_AttackOnlyRule_GatedByMode() {
// Ensure clean state on entry and on exit (avoid bleed-through if assertions abort).
s.mgmtRPC("attack.disable", "{}")
defer s.mgmtRPC("attack.disable", "{}")

const attackUA = "E2EAttackBot/1.0"

// Peace time: the AttackOnly rule must NOT fire.
s.Equal(http.StatusOK, s.getStatus(dataURL+"/", attackUA),
"AttackOnly rule must be dormant when attack mode is off")

// Enable attack mode and observe the rule blocking the same UA.
result := s.mgmtRPC("attack.enable", `{"duration":"5m"}`)
s.Contains(result, `"enabled":true`)

s.Equal(http.StatusForbidden, s.getStatus(dataURL+"/", attackUA),
"AttackOnly rule must block once attack mode is on")

// Metric should reflect at least one fire of the AttackOnly rule.
s.Contains(s.getMetrics(),
`wafsrv_attack_only_match_total{rule="e2e-attack-only-bot"}`,
"AttackOnly counter must expose the rule label")

// Disable: rule goes dormant again.
s.mgmtRPC("attack.disable", "{}")
s.Equal(http.StatusOK, s.getStatus(dataURL+"/", attackUA),
"AttackOnly rule must stop firing once attack mode is off")
}

// getStatus issues a GET with a custom User-Agent and returns the response status code.
func (s *E2ESuite) getStatus(url, ua string) int {
s.T().Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
s.Require().NoError(err)
req.Header.Set("User-Agent", ua)
resp, err := http.DefaultClient.Do(req)
s.Require().NoError(err)
defer resp.Body.Close()
return resp.StatusCode
}

// --- 12: Metrics Consistency ---

func (s *E2ESuite) Test12_Metrics_Consistency() {
Expand Down
9 changes: 6 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,14 @@ func New(appName string, sl embedlog.Logger, cfg Config) (*App, error) {
event.NewTops(30*time.Minute, 10000),
)

// AttackService must exist before WAF wire-up: filter and decide read it via waf.AttackState DI.
a.attackSvc = dashboard.NewAttackService()

if err := a.initWAF(); err != nil {
return nil, err
}

a.platformSet = a.buildPlatformSet()
a.attackSvc = dashboard.NewAttackService()
a.initDecision()
a.initAdaptive()

Expand Down Expand Up @@ -203,7 +205,7 @@ func (a *App) initWAF() error {
}

if a.cfg.TrafficFilter.TrafficFilterEnabled() {
a.trafficFilter = filter.New(a.cfg.TrafficFilter.Rules, a.Logger, a.metrics.filterMetrics(a.recorder))
a.trafficFilter = filter.New(a.cfg.TrafficFilter.Rules, a.attackSvc, a.Logger, a.metrics.filterMetrics(a.recorder))
}

if a.cfg.Signing.SigningEnabled() {
Expand Down Expand Up @@ -361,6 +363,7 @@ func (a *App) initDecision() {
a.decisionEngine = decide.New(decide.Config{
CaptchaThreshold: a.cfg.Decision.CaptchaThreshold,
BlockThreshold: a.cfg.Decision.BlockThreshold,
AttackScoreBoost: a.cfg.Adaptive.AutoAttack.ScoreBoost,
CaptchaStatusCode: a.cfg.Decision.CaptchaStatusCode,
BlockStatusCode: a.cfg.Decision.BlockStatusCode,
CaptchaToBlock: a.cfg.Decision.CaptchaToBlock,
Expand All @@ -376,7 +379,7 @@ func (a *App) initDecision() {
Title: "Security Check",
PrimaryColor: "#4F46E5",
},
}, a.kvStore, a.captchaCache, verifier, powVerifier, a.alertSender(), a.Logger, a.metrics.decideMetrics(a.recorder, a.platformSet))
}, a.kvStore, a.captchaCache, verifier, powVerifier, a.alertSender(), a.attackSvc, a.Logger, a.metrics.decideMetrics(a.recorder, a.platformSet))
}

// Run starts both data and management servers.
Expand Down
22 changes: 22 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type AutoAttackConfig struct {
ErrorRateThreshold float64 // default 20
LatencyThresholdMs float64 // default 500
BlockedRateThreshold float64 // default 50
ScoreBoost float64 // default 0; +N to score during attack mode (Decision.AttackScoreBoost)
Window string // default "1m"
Cooldown string // default "5m"
Duration string // default "10m"
Expand Down Expand Up @@ -523,6 +524,27 @@ func (c *Config) Validate() error {
return err
}

if err := c.adaptiveBoostValidate(); err != nil {
return err
}

return nil
}

// adaptiveBoostValidate ensures Adaptive.AutoAttack.ScoreBoost cannot push
// every request straight into the block bucket while attack mode is on —
// boost must leave at least the captcha gap below the captcha threshold.
func (c *Config) adaptiveBoostValidate() error {
boost := c.Adaptive.AutoAttack.ScoreBoost
if boost <= 0 {
return nil
}

if c.Decision.CaptchaThreshold > 0 && boost >= c.Decision.CaptchaThreshold {
return fmt.Errorf("config: Adaptive.AutoAttack.ScoreBoost (%.1f) must be < Decision.CaptchaThreshold (%.1f), otherwise every request gets challenged in attack mode",
boost, c.Decision.CaptchaThreshold)
}

return nil
}

Expand Down
37 changes: 37 additions & 0 deletions internal/app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,43 @@ Limit = "10/min"
s.Contains(err.Error(), "Path/Method/Host")
}

func (s *ConfigSuite) TestValidateAdaptiveBoostExceedsCaptchaThreshold() {
path := s.writeConfig(`
[Proxy]
Targets = ["http://localhost:3000"]
ServiceName = "test-svc"

[Decision]
CaptchaThreshold = 5
BlockThreshold = 8

[Adaptive.AutoAttack]
ScoreBoost = 6
`)

_, err := LoadConfig(path)
s.Require().Error(err, "ScoreBoost >= CaptchaThreshold must fail validation")
s.Contains(err.Error(), "ScoreBoost")
}

func (s *ConfigSuite) TestValidateAdaptiveBoostBelowThresholdOK() {
path := s.writeConfig(`
[Proxy]
Targets = ["http://localhost:3000"]
ServiceName = "test-svc"

[Decision]
CaptchaThreshold = 5
BlockThreshold = 8

[Adaptive.AutoAttack]
ScoreBoost = 2
`)

_, err := LoadConfig(path)
s.Require().NoError(err, "ScoreBoost < CaptchaThreshold should be OK")
}

func (s *ConfigSuite) TestValidateDuplicateRuleName() {
path := s.writeConfig(`
[Proxy]
Expand Down
18 changes: 17 additions & 1 deletion internal/app/config_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func BuildConfigResponse(c Config) dashboard.ConfigResponse {
WAF: dashboard.WAFSection{Enabled: c.WAF.WAFEnabled(), Mode: c.WAF.Mode, ParanoiaLevel: c.WAF.ParanoiaLevel},
RateLimit: buildRateLimitSection(c),
IP: buildIPSection(c),
TrafficFilter: dashboard.TrafficFilterSection{Enabled: c.TrafficFilter.TrafficFilterEnabled(), RuleCount: len(c.TrafficFilter.Rules)},
TrafficFilter: buildTrafficFilterSection(c),
Signing: buildSigningSection(c),
Decision: buildDecisionSection(c),
Captcha: dashboard.CaptchaSection{Provider: c.Captcha.Provider, HasKeys: c.Captcha.SiteKey != "" && c.Captcha.SecretKey != "", CookieName: c.Captcha.CookieName, CookieTTL: c.Captcha.CookieTTL, IPCacheTTL: c.Captcha.IPCacheTTL},
Expand Down Expand Up @@ -156,6 +156,21 @@ func buildDecisionSection(c Config) dashboard.DecisionSection {
}
}

func buildTrafficFilterSection(c Config) dashboard.TrafficFilterSection {
attackOnly := 0
for _, r := range c.TrafficFilter.Rules {
if r.AttackOnly {
attackOnly++
}
}

return dashboard.TrafficFilterSection{
Enabled: c.TrafficFilter.TrafficFilterEnabled(),
RuleCount: len(c.TrafficFilter.Rules),
AttackOnlyCount: attackOnly,
}
}

func buildAdaptiveSection(c Config) dashboard.AdaptiveSection {
aa := c.Adaptive.AutoAttack
return dashboard.AdaptiveSection{
Expand All @@ -169,6 +184,7 @@ func buildAdaptiveSection(c Config) dashboard.AdaptiveSection {
ErrorRate: aa.ErrorRateThreshold,
LatencyMs: aa.LatencyThresholdMs,
BlockedRate: aa.BlockedRateThreshold,
ScoreBoost: aa.ScoreBoost,
Window: aa.Window,
Cooldown: aa.Cooldown,
Duration: aa.Duration,
Expand Down
32 changes: 26 additions & 6 deletions internal/app/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const (
metricAdaptiveTrigger = "wafsrv_adaptive_trigger_total"
metricAdaptiveAttack = "wafsrv_adaptive_attack_total"
metricProxyErrorsTotal = "wafsrv_proxy_errors_total"
metricAttackOnlyMatch = "wafsrv_attack_only_match_total"
metricAttackScoreBoost = "wafsrv_attack_score_boost_applied_total"
)

// appMetrics holds all prometheus metrics for the application.
Expand Down Expand Up @@ -69,10 +71,14 @@ type appMetrics struct {

// proxy
proxyErrorsTotal *prometheus.CounterVec

// adaptive captcha expansion
attackOnlyTotal *prometheus.CounterVec
attackScoreBoostUsed *prometheus.CounterVec
}

// newMetrics creates all prometheus metrics and registers them.
func newMetrics() *appMetrics {
func newMetrics() *appMetrics { //nolint:funlen // flat constructor, no logic to extract
m := &appMetrics{
registry: prometheus.NewRegistry(),

Expand Down Expand Up @@ -146,6 +152,16 @@ func newMetrics() *appMetrics {
Name: metricProxyErrorsTotal,
Help: "Total proxy errors by target and reason (cb_open / upstream_error / no_backends).",
}, []string{"target", "reason"}),

attackOnlyTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: metricAttackOnlyMatch,
Help: "Total fires of AttackOnly traffic-filter rules (Under Attack Mode).",
}, []string{"rule"}),

attackScoreBoostUsed: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: metricAttackScoreBoost,
Help: "Total requests where AttackScoreBoost moved the decision (captcha/block) during Under Attack Mode.",
}, []string{"result"}),
}

m.registry.MustRegister(
Expand All @@ -163,6 +179,8 @@ func newMetrics() *appMetrics {
m.adaptiveTrigger,
m.adaptiveAttack,
m.proxyErrorsTotal,
m.attackOnlyTotal,
m.attackScoreBoostUsed,
)

return m
Expand Down Expand Up @@ -193,8 +211,9 @@ func (m *appMetrics) ipMetrics(rec *event.Recorder) ip.Metrics {
// filterMetrics returns filter.Metrics populated from appMetrics.
func (m *appMetrics) filterMetrics(rec *event.Recorder) filter.Metrics {
return filter.Metrics{
MatchedTotal: m.filterMatchedTotal,
Recorder: rec,
MatchedTotal: m.filterMatchedTotal,
AttackOnlyTotal: m.attackOnlyTotal,
Recorder: rec,
}
}

Expand Down Expand Up @@ -225,9 +244,10 @@ func (m *appMetrics) signMetrics(rec *event.Recorder) sign.Metrics {
// decideMetrics returns decide.Metrics populated from appMetrics.
func (m *appMetrics) decideMetrics(rec *event.Recorder, platformSet map[string]struct{}) decide.Metrics {
return decide.Metrics{
DecisionTotal: m.decisionTotal,
Recorder: rec,
PlatformSet: platformSet,
DecisionTotal: m.decisionTotal,
AttackBoostUsed: m.attackScoreBoostUsed,
Recorder: rec,
PlatformSet: platformSet,
}
}

Expand Down
31 changes: 31 additions & 0 deletions internal/app/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app

import (
"testing"

"github.com/stretchr/testify/suite"
)

type MetricsSuite struct {
suite.Suite
}

func TestMetrics(t *testing.T) {
suite.Run(t, new(MetricsSuite))
}

// TestWireUp guards against silent regressions where a new metric is added
// to a sub-package's Metrics struct but the app-side adapter forgets to
// populate it. A nil CounterVec passed to a hot-path middleware is a no-op
// that hides the missing metric in production.
func (s *MetricsSuite) TestWireUp() {
m := newMetrics()

fm := m.filterMetrics(nil)
s.NotNil(fm.MatchedTotal, "filter.Metrics.MatchedTotal must be wired")
s.NotNil(fm.AttackOnlyTotal, "filter.Metrics.AttackOnlyTotal must be wired")

dm := m.decideMetrics(nil, nil)
s.NotNil(dm.DecisionTotal, "decide.Metrics.DecisionTotal must be wired")
s.NotNil(dm.AttackBoostUsed, "decide.Metrics.AttackBoostUsed must be wired")
}
4 changes: 4 additions & 0 deletions internal/app/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ func accessLogRCAttrs(rc *waf.RequestContext) []slog.Attr {
attrs = append(attrs, slog.Float64("wafScore", rc.WAFScore))
}

if rc.AttackBoost > 0 {
attrs = append(attrs, slog.Float64("scoreBoost", rc.AttackBoost))
}

if rc.Static {
attrs = append(attrs, slog.Bool("static", true))
}
Expand Down
Loading
Loading