From 5a5ec1ed28ac79191dc63246db4063d90232707e Mon Sep 17 00:00:00 2001 From: Sergey Bykov Date: Fri, 1 May 2026 11:20:19 +0300 Subject: [PATCH] Add Under Attack Mode and clean example hosts - Add Decision.AttackScoreBoost in internal/waf/decide for Under Attack Mode: boosts request score when waf.AttackState is on - Gate TrafficRule (filter) with AttackOnly via waf.AttackState in internal/waf/filter; rule fires only while attack mode is active - Tighten hot-path cost of attack-mode reads (atomic snapshot) - Surface AttackOnly and AdaptiveSection.ScoreBoost in dashboard (internal/dashboard/attack.go, config.go, dashboard_zenrpc.go) and config builder UI with TOML round-tripping - Add e2e test for AttackOnly rule gating (e2e/management_test.go, e2e/cfg/e2e.toml) - Add metrics tests in internal/app/metrics_test.go - Replace concrete hostnames with example.com in code, templates and config samples --- README.md | 4 +- cfg/local.toml.dist | 9 ++ e2e/cfg/e2e.toml | 6 + e2e/management_test.go | 41 +++++ internal/app/app.go | 9 +- internal/app/config.go | 22 +++ internal/app/config_test.go | 37 +++++ internal/app/config_view.go | 18 ++- internal/app/metrics.go | 32 +++- internal/app/metrics_test.go | 31 ++++ internal/app/middleware.go | 4 + internal/dashboard/attack.go | 143 ++++++++++-------- internal/dashboard/config.go | 6 +- internal/dashboard/dashboard_zenrpc.go | 15 +- .../dashboard/web/builder/builder-config.js | 3 +- .../dashboard/web/builder/builder-import.js | 3 +- .../dashboard/web/builder/builder-toml.js | 3 + internal/dashboard/web/builder/index.html | 2 + internal/dashboard/web/index.html | 2 + internal/waf/decide/decide.go | 53 ++++++- internal/waf/decide/decide_test.go | 92 ++++++++++- internal/waf/filter/filter.go | 34 ++++- internal/waf/filter/filter_test.go | 86 +++++++++++ internal/waf/model.go | 8 + 24 files changed, 571 insertions(+), 92 deletions(-) create mode 100644 internal/app/metrics_test.go diff --git a/README.md b/README.md index 2bd37da..b9183da 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/cfg/local.toml.dist b/cfg/local.toml.dist index d48d8a7..7d68d3d 100644 --- a/cfg/local.toml.dist +++ b/cfg/local.toml.dist @@ -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/"] diff --git a/e2e/cfg/e2e.toml b/e2e/cfg/e2e.toml index 547f7b0..f98342c 100644 --- a/e2e/cfg/e2e.toml +++ b/e2e/cfg/e2e.toml @@ -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" diff --git a/e2e/management_test.go b/e2e/management_test.go index 57d7fe7..d915d60 100644 --- a/e2e/management_test.go +++ b/e2e/management_test.go @@ -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() { diff --git a/internal/app/app.go b/internal/app/app.go index 3155185..c081c9a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() @@ -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() { @@ -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, @@ -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. diff --git a/internal/app/config.go b/internal/app/config.go index 8265a18..ed6c406 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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" @@ -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 } diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 30bfafe..c21402e 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -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] diff --git a/internal/app/config_view.go b/internal/app/config_view.go index f15eadb..5bbd57c 100644 --- a/internal/app/config_view.go +++ b/internal/app/config_view.go @@ -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}, @@ -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{ @@ -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, diff --git a/internal/app/metrics.go b/internal/app/metrics.go index bd8f014..bf18dcf 100644 --- a/internal/app/metrics.go +++ b/internal/app/metrics.go @@ -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. @@ -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(), @@ -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( @@ -163,6 +179,8 @@ func newMetrics() *appMetrics { m.adaptiveTrigger, m.adaptiveAttack, m.proxyErrorsTotal, + m.attackOnlyTotal, + m.attackScoreBoostUsed, ) return m @@ -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, } } @@ -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, } } diff --git a/internal/app/metrics_test.go b/internal/app/metrics_test.go new file mode 100644 index 0000000..51a8182 --- /dev/null +++ b/internal/app/metrics_test.go @@ -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") +} diff --git a/internal/app/middleware.go b/internal/app/middleware.go index 7473df9..724a0bf 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -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)) } diff --git a/internal/dashboard/attack.go b/internal/dashboard/attack.go index bb3ada3..77b6446 100644 --- a/internal/dashboard/attack.go +++ b/internal/dashboard/attack.go @@ -3,21 +3,28 @@ package dashboard import ( "context" "sync" + "sync/atomic" "time" "github.com/vmkteam/zenrpc/v2" ) -// attackState holds the shared mutable state (survives zenrpc value-receiver copies). -type attackState struct { - mu sync.Mutex +// snapshot is an immutable view of the attack-mode state. Readers load the +// pointer atomically; writers serialize via mu, build a new snapshot and Store. +type snapshot struct { enabled bool - source string // "manual" | "auto" + source string triggers []string since time.Time expiresAt time.Time } +// attackState holds the shared mutable state (survives zenrpc value-receiver copies). +type attackState struct { + mu sync.Mutex + snap atomic.Pointer[snapshot] +} + // AttackService manages Under Attack Mode. type AttackService struct { zenrpc.Service @@ -26,7 +33,10 @@ type AttackService struct { // NewAttackService creates a new AttackService. func NewAttackService() *AttackService { - return &AttackService{state: &attackState{}} + st := &attackState{} + st.snap.Store(&snapshot{}) + + return &AttackService{state: st} } // AttackStatus is the response for attack.status. @@ -46,11 +56,8 @@ func (s AttackService) Enable(_ context.Context, duration string) (AttackStatus, s.state.mu.Lock() defer s.state.mu.Unlock() - s.state.enabled = true - s.state.source = "manual" - s.state.triggers = nil - s.state.since = time.Now() - s.state.expiresAt = time.Time{} + now := time.Now() + next := snapshot{enabled: true, source: "manual", since: now} if duration != "" { d, err := time.ParseDuration(duration) @@ -58,10 +65,12 @@ func (s AttackService) Enable(_ context.Context, duration string) (AttackStatus, return AttackStatus{}, ErrBadRequest } - s.state.expiresAt = s.state.since.Add(d) + next.expiresAt = now.Add(d) } - return s.state.statusLocked(), nil + s.state.snap.Store(&next) + + return s.state.statusFromSnapshot(&next), nil } // Disable deactivates Under Attack Mode. @@ -71,35 +80,35 @@ func (s AttackService) Disable(_ context.Context) AttackStatus { s.state.mu.Lock() defer s.state.mu.Unlock() - s.state.enabled = false - s.state.source = "" - s.state.triggers = nil - s.state.since = time.Time{} - s.state.expiresAt = time.Time{} + next := snapshot{} + s.state.snap.Store(&next) - return s.state.statusLocked() + return s.state.statusFromSnapshot(&next) } // Status returns the current Under Attack Mode state. // //zenrpc:return AttackStatus func (s AttackService) Status(_ context.Context) AttackStatus { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - s.state.expireIfNeeded() - - return s.state.statusLocked() + cur := s.state.expireIfNeeded() + return s.state.statusFromSnapshot(cur) } -// IsEnabled returns whether Under Attack Mode is active (for middleware). +// IsEnabled returns whether Under Attack Mode is active. Lock-free fast path +// (single atomic load) so it can be called from per-request middleware. func (s AttackService) IsEnabled() bool { - s.state.mu.Lock() - defer s.state.mu.Unlock() + cur := s.state.snap.Load() + if !cur.enabled { + return false + } - s.state.expireIfNeeded() + if !cur.expiresAt.IsZero() && time.Now().After(cur.expiresAt) { + // CAS-style expiry: only first caller wins, others observe disabled. + next := s.state.expireIfNeeded() + return next.enabled + } - return s.state.enabled + return true } // EnableAuto activates Under Attack Mode from adaptive engine. @@ -107,16 +116,21 @@ func (s AttackService) EnableAuto(duration time.Duration, triggers []string) { s.state.mu.Lock() defer s.state.mu.Unlock() + cur := s.state.snap.Load() + // don't override manual attack - if s.state.enabled && s.state.source == "manual" { + if cur.enabled && cur.source == "manual" { return } - s.state.enabled = true - s.state.source = "auto" - s.state.triggers = triggers - s.state.since = time.Now() - s.state.expiresAt = s.state.since.Add(duration) + now := time.Now() + s.state.snap.Store(&snapshot{ + enabled: true, + source: "auto", + triggers: triggers, + since: now, + expiresAt: now.Add(duration), + }) } // DisableAuto deactivates Under Attack Mode (for adaptive engine, no context needed). @@ -124,49 +138,56 @@ func (s AttackService) DisableAuto() { s.state.mu.Lock() defer s.state.mu.Unlock() - s.state.enabled = false - s.state.source = "" - s.state.triggers = nil - s.state.since = time.Time{} - s.state.expiresAt = time.Time{} + s.state.snap.Store(&snapshot{}) } // Source returns the source of the current attack mode ("manual", "auto", or ""). func (s AttackService) Source() string { - s.state.mu.Lock() - defer s.state.mu.Unlock() - - if !s.state.enabled { + cur := s.state.snap.Load() + if !cur.enabled { return "" } - return s.state.source + return cur.source } -func (st *attackState) expireIfNeeded() { - if st.enabled && !st.expiresAt.IsZero() && time.Now().After(st.expiresAt) { - st.enabled = false - st.source = "" - st.triggers = nil - st.since = time.Time{} - st.expiresAt = time.Time{} +// expireIfNeeded clears expired state under the write lock and returns the +// (possibly updated) snapshot. Always reflects the post-call state. +func (st *attackState) expireIfNeeded() *snapshot { + cur := st.snap.Load() + if !cur.enabled || cur.expiresAt.IsZero() || !time.Now().After(cur.expiresAt) { + return cur + } + + st.mu.Lock() + defer st.mu.Unlock() + + // re-check under lock — another goroutine may have cleared first + cur = st.snap.Load() + if !cur.enabled || cur.expiresAt.IsZero() || !time.Now().After(cur.expiresAt) { + return cur } + + next := &snapshot{} + st.snap.Store(next) + + return next } -func (st *attackState) statusLocked() AttackStatus { - as := AttackStatus{Enabled: st.enabled} +func (st *attackState) statusFromSnapshot(snap *snapshot) AttackStatus { + as := AttackStatus{Enabled: snap.enabled} - if st.enabled { - as.Source = st.source - as.Triggers = st.triggers + if snap.enabled { + as.Source = snap.source + as.Triggers = snap.triggers } - if !st.since.IsZero() { - as.Since = st.since.Format(time.RFC3339) + if !snap.since.IsZero() { + as.Since = snap.since.Format(time.RFC3339) } - if !st.expiresAt.IsZero() { - as.ExpiresAt = st.expiresAt.Format(time.RFC3339) + if !snap.expiresAt.IsZero() { + as.ExpiresAt = snap.expiresAt.Format(time.RFC3339) } return as diff --git a/internal/dashboard/config.go b/internal/dashboard/config.go index 36c44ef..ab2959a 100644 --- a/internal/dashboard/config.go +++ b/internal/dashboard/config.go @@ -139,8 +139,9 @@ type ReputationInfo struct { } type TrafficFilterSection struct { - Enabled bool `json:"enabled"` - RuleCount int `json:"ruleCount"` + Enabled bool `json:"enabled"` + RuleCount int `json:"ruleCount"` + AttackOnlyCount int `json:"attackOnlyCount"` // rules dormant outside Under Attack Mode } type SigningSection struct { @@ -197,6 +198,7 @@ type AdaptiveSection struct { ErrorRate float64 `json:"errorRate"` LatencyMs float64 `json:"latencyMs"` BlockedRate float64 `json:"blockedRate"` + ScoreBoost float64 `json:"scoreBoost"` // additive WAFScore bonus during attack mode (Adaptive.AutoAttack.ScoreBoost) Window string `json:"window"` Cooldown string `json:"cooldown"` Duration string `json:"duration"` diff --git a/internal/dashboard/dashboard_zenrpc.go b/internal/dashboard/dashboard_zenrpc.go index ad05061..90ef7b6 100644 --- a/internal/dashboard/dashboard_zenrpc.go +++ b/internal/dashboard/dashboard_zenrpc.go @@ -178,8 +178,9 @@ func (AttackService) SMD() smd.ServiceInfo { }, }, "IsEnabled": { - Description: `IsEnabled returns whether Under Attack Mode is active (for middleware).`, - Parameters: []smd.JSONSchema{}, + Description: `IsEnabled returns whether Under Attack Mode is active. Lock-free fast path +(single atomic load) so it can be called from per-request middleware.`, + Parameters: []smd.JSONSchema{}, Returns: smd.JSONSchema{ Type: smd.Boolean, }, @@ -961,6 +962,11 @@ func (ConfigService) SMD() smd.ServiceInfo { Name: "ruleCount", Type: smd.Integer, }, + { + Name: "attackOnlyCount", + Description: `rules dormant outside Under Attack Mode`, + Type: smd.Integer, + }, }, }, "SigningSection": { @@ -1146,6 +1152,11 @@ func (ConfigService) SMD() smd.ServiceInfo { Name: "blockedRate", Type: smd.Float, }, + { + Name: "scoreBoost", + Description: `additive WAFScore bonus during attack mode (Decision.AttackScoreBoost)`, + Type: smd.Float, + }, { Name: "window", Type: smd.String, diff --git a/internal/dashboard/web/builder/builder-config.js b/internal/dashboard/web/builder/builder-config.js index baacb8b..151ea46 100644 --- a/internal/dashboard/web/builder/builder-config.js +++ b/internal/dashboard/web/builder/builder-config.js @@ -63,6 +63,7 @@ function builderConfig() { autoAttack: { rpsMultiplier: 3.0, rpsRecoveryMultiplier: 1.5, minRPS: 10, errorRateThreshold: 20, latencyThresholdMs: 500, blockedRateThreshold: 50, + scoreBoost: 0, window: '1m', cooldown: '5m', duration: '10m', }, }, @@ -96,7 +97,7 @@ function builderConfig() { defaultTrafficRule() { return { - name: '', action: 'block', + name: '', action: 'block', attackOnly: false, uaPrefix: [], uaContains: [], uaExact: [], uaExclude: [], country: [], platform: [], version: [], ip: [], asn: [], rpcMethod: [], diff --git a/internal/dashboard/web/builder/builder-import.js b/internal/dashboard/web/builder/builder-import.js index 198240b..e995b96 100644 --- a/internal/dashboard/web/builder/builder-import.js +++ b/internal/dashboard/web/builder/builder-import.js @@ -280,7 +280,7 @@ function builderImport() { if (t.TrafficFilter.Rules) { // Use trafficConditions as single source of truth for TOML↔JS mapping cfg.trafficFilter.rules = t.TrafficFilter.Rules.map(r => { - const rule = {...this.defaultTrafficRule(), name: r.Name || '', action: r.Action || 'block'}; + const rule = {...this.defaultTrafficRule(), name: r.Name || '', action: r.Action || 'block', attackOnly: !!r.AttackOnly}; for (const cond of this.trafficConditions) { if (r[cond.toml]) rule[cond.key] = [...r[cond.toml]]; } @@ -379,6 +379,7 @@ function builderImport() { if (a.ErrorRateThreshold !== undefined) aa.errorRateThreshold = a.ErrorRateThreshold; if (a.LatencyThresholdMs !== undefined) aa.latencyThresholdMs = a.LatencyThresholdMs; if (a.BlockedRateThreshold !== undefined) aa.blockedRateThreshold = a.BlockedRateThreshold; + if (a.ScoreBoost !== undefined) aa.scoreBoost = a.ScoreBoost; if (a.Window) aa.window = a.Window; if (a.Cooldown) aa.cooldown = a.Cooldown; if (a.Duration) aa.duration = a.Duration; diff --git a/internal/dashboard/web/builder/builder-toml.js b/internal/dashboard/web/builder/builder-toml.js index 471ba0a..471bf06 100644 --- a/internal/dashboard/web/builder/builder-toml.js +++ b/internal/dashboard/web/builder/builder-toml.js @@ -288,6 +288,7 @@ function builderToml() { lines.push('[[TrafficFilter.Rules]]'); lines.push(`Name = ${this._q(r.name)}`); lines.push(`Action = ${this._q(r.action)}`); + if (r.attackOnly) lines.push('AttackOnly = true'); for (const cond of this.trafficConditions) { if (r[cond.key] && r[cond.key].length > 0) { const vals = cond.numeric ? r[cond.key].join(', ') : r[cond.key].map(v => this._q(v)).join(', '); @@ -447,6 +448,7 @@ function builderToml() { const aaChanged = aa.rpsMultiplier !== daa.rpsMultiplier || aa.rpsRecoveryMultiplier !== daa.rpsRecoveryMultiplier || aa.minRPS !== daa.minRPS || aa.errorRateThreshold !== daa.errorRateThreshold || aa.latencyThresholdMs !== daa.latencyThresholdMs || aa.blockedRateThreshold !== daa.blockedRateThreshold || + aa.scoreBoost !== daa.scoreBoost || aa.window !== daa.window || aa.cooldown !== daa.cooldown || aa.duration !== daa.duration; if (aaChanged) { @@ -458,6 +460,7 @@ function builderToml() { if (aa.errorRateThreshold !== daa.errorRateThreshold) lines.push(`ErrorRateThreshold = ${aa.errorRateThreshold}`); if (aa.latencyThresholdMs !== daa.latencyThresholdMs) lines.push(`LatencyThresholdMs = ${aa.latencyThresholdMs}`); if (aa.blockedRateThreshold !== daa.blockedRateThreshold) lines.push(`BlockedRateThreshold = ${aa.blockedRateThreshold}`); + if (aa.scoreBoost !== daa.scoreBoost) lines.push(`ScoreBoost = ${aa.scoreBoost}`); if (aa.window !== daa.window) lines.push(`Window = ${this._q(aa.window)}`); if (aa.cooldown !== daa.cooldown) lines.push(`Cooldown = ${this._q(aa.cooldown)}`); if (aa.duration !== daa.duration) lines.push(`Duration = ${this._q(aa.duration)}`); diff --git a/internal/dashboard/web/builder/index.html b/internal/dashboard/web/builder/index.html index 37fa7a9..ab4e16f 100644 --- a/internal/dashboard/web/builder/index.html +++ b/internal/dashboard/web/builder/index.html @@ -531,6 +531,7 @@

Custom Feeds

+
@@ -836,6 +837,7 @@

Platform Captcha Policy

+

+N to WAFScore while attack mode is on. Must be < Decision.CaptchaThreshold.

diff --git a/internal/dashboard/web/index.html b/internal/dashboard/web/index.html index 98ecc29..0639faf 100644 --- a/internal/dashboard/web/index.html +++ b/internal/dashboard/web/index.html @@ -684,6 +684,7 @@

Reputation Feeds

Enabled
Static Rules
+
Attack-Only Rules
@@ -770,6 +771,7 @@

Triggers

Error Rate
Latency
Blocked Rate
+
Score Boost
Window
Cooldown
Duration
diff --git a/internal/waf/decide/decide.go b/internal/waf/decide/decide.go index 01a0ef1..3589b92 100644 --- a/internal/waf/decide/decide.go +++ b/internal/waf/decide/decide.go @@ -24,6 +24,7 @@ import ( type Config struct { CaptchaThreshold float64 BlockThreshold float64 + AttackScoreBoost float64 // additive bonus to score during Under Attack Mode CaptchaStatusCode int BlockStatusCode int CaptchaToBlock int @@ -48,9 +49,10 @@ type PlatformConfig struct { // Metrics holds decision engine prometheus metrics. type Metrics struct { - DecisionTotal *prometheus.CounterVec - Recorder *event.Recorder - PlatformSet map[string]struct{} + DecisionTotal *prometheus.CounterVec + AttackBoostUsed *prometheus.CounterVec // labels: result (captcha|block) — fires when boost moved decision + Recorder *event.Recorder + PlatformSet map[string]struct{} } // Engine evaluates request score and decides pass/captcha/block. @@ -61,6 +63,7 @@ type Engine struct { powVerifier *challenge.PowVerifier store storage.KVStore alerter alerting.Sender + attackState waf.AttackState embedlog.Logger metrics Metrics } @@ -71,8 +74,10 @@ type scoreEntry struct { BlockedUntil time.Time `json:"b"` } -// New creates a new decision engine. -func New(cfg Config, store storage.KVStore, cache *challenge.Cache, verifier *challenge.Verifier, powVerifier *challenge.PowVerifier, alerter alerting.Sender, sl embedlog.Logger, metrics Metrics) *Engine { +// New creates a new decision engine. attackState MUST be non-nil — it is read +// on every request to gate AttackScoreBoost. Pass dashboard.AttackService in +// production; a stub `IsEnabled() bool { return false }` works in tests. +func New(cfg Config, store storage.KVStore, cache *challenge.Cache, verifier *challenge.Verifier, powVerifier *challenge.PowVerifier, alerter alerting.Sender, attackState waf.AttackState, sl embedlog.Logger, metrics Metrics) *Engine { return &Engine{ cfg: cfg, store: store, @@ -80,6 +85,7 @@ func New(cfg Config, store storage.KVStore, cache *challenge.Cache, verifier *ch verifier: verifier, powVerifier: powVerifier, alerter: alerter, + attackState: attackState, Logger: sl, metrics: metrics, } @@ -125,6 +131,24 @@ func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocogni score := rc.WAFScore + // Under Attack Mode: lift score by AttackScoreBoost so borderline + // requests fall into captcha/block buckets without changing thresholds. + // rc.WAFScore stays authoritative for upstream scorers; rc.AttackBoost + // records the applied delta so access logs can correlate boosted + // decisions with this request. + if e.cfg.AttackScoreBoost > 0 && e.attackState.IsEnabled() { + boosted := score + e.cfg.AttackScoreBoost + rc.AttackBoost = e.cfg.AttackScoreBoost + + if e.metrics.AttackBoostUsed != nil { + if result := e.boostCrossing(score, boosted); result != "" { + e.metrics.AttackBoostUsed.WithLabelValues(result).Inc() + } + } + + score = boosted + } + // no thresholds configured — pass through if e.cfg.BlockThreshold == 0 && e.cfg.CaptchaThreshold == 0 { rc.Decision = waf.ActionPass @@ -324,6 +348,25 @@ func (e *Engine) recordCaptcha(ctx context.Context, key string) { } } +// Metric label values for AttackBoostUsed. +const ( + boostResultBlock = "block" + boostResultCaptcha = "captcha" +) + +// boostCrossing returns the threshold the boost pushed the score across, or "" +// if the boost was a no-op (already above or still below thresholds). +func (e *Engine) boostCrossing(orig, boosted float64) string { + switch { + case e.cfg.BlockThreshold > 0 && boosted >= e.cfg.BlockThreshold && orig < e.cfg.BlockThreshold: + return boostResultBlock + case e.cfg.CaptchaThreshold > 0 && boosted >= e.cfg.CaptchaThreshold && orig < e.cfg.CaptchaThreshold: + return boostResultCaptcha + default: + return "" + } +} + func (e *Engine) metricPlatform(platform string) string { if platform == "" { return "" diff --git a/internal/waf/decide/decide_test.go b/internal/waf/decide/decide_test.go index 8d0c1a0..46a8311 100644 --- a/internal/waf/decide/decide_test.go +++ b/internal/waf/decide/decide_test.go @@ -12,6 +12,7 @@ import ( "wafsrv/internal/waf/storage" "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/suite" "github.com/vmkteam/embedlog" ) @@ -24,6 +25,13 @@ func TestDecide(t *testing.T) { suite.Run(t, new(DecideSuite)) } +// fakeAttackState is a controllable AttackState for tests. +type fakeAttackState struct{ on bool } + +func (f *fakeAttackState) IsEnabled() bool { return f.on } + +func attackOff() waf.AttackState { return &fakeAttackState{on: false} } + func (s *DecideSuite) TestPassBelowThreshold() { e := s.newEngine(5, 8) @@ -93,7 +101,7 @@ func (s *DecideSuite) TestCaptchaPassCacheBypass() { BlockThreshold: 8, CaptchaStatusCode: 499, BlockStatusCode: http.StatusForbidden, - }, kvStore, cache, nil, nil, nil, embedlog.NewLogger(false, false), testMetrics()) + }, kvStore, cache, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) handler := e.Middleware()(okHandler()) @@ -141,6 +149,80 @@ func (s *DecideSuite) TestZeroThresholdsPass() { s.Equal(http.StatusOK, w.Code, "zero thresholds should pass everything") } +func (s *DecideSuite) TestAttackScoreBoost() { + cases := []struct { + name string + boost float64 + attackOn bool + score float64 + wantCode int + wantBoosted bool // rc.AttackBoost should be set on the request + wantCross string // "" or boostResultBlock/Captcha + }{ + {"boost off → pass", 2, false, 3, http.StatusOK, false, ""}, + {"boost on, lifts to captcha", 2, true, 3, 499, true, boostResultCaptcha}, + {"boost on, lifts to block", 5, true, 4, http.StatusForbidden, true, boostResultBlock}, + {"boost zero, on, ignored", 0, true, 3, http.StatusOK, false, ""}, + {"boost on, score already above block", 2, true, 9, http.StatusForbidden, true, ""}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + st := &fakeAttackState{on: tc.attackOn} + boostMetric := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "test_decide_attack_boost_used_total", + Help: "test", + }, []string{"result"}) + metrics := testMetrics() + metrics.AttackBoostUsed = boostMetric + + e := New(Config{ + CaptchaThreshold: 5, + BlockThreshold: 8, + AttackScoreBoost: tc.boost, + CaptchaStatusCode: 499, + BlockStatusCode: http.StatusForbidden, + CaptchaToBlock: 3, + CaptchaToBlockWindow: 10 * time.Minute, + SoftBlockDuration: 10 * time.Minute, + }, storage.NewMemoryKV(100000), nil, nil, nil, nil, st, embedlog.NewLogger(false, false), metrics) + + handler := e.Middleware()(okHandler()) + w := httptest.NewRecorder() + req := s.requestWithScore("9.9.9.9", tc.score) + handler.ServeHTTP(w, req) + + s.Equal(tc.wantCode, w.Code) + + rc := waf.FromContext(req.Context()) + if tc.wantBoosted { + s.InDelta(tc.boost, rc.AttackBoost, 0.0001, "rc.AttackBoost should record applied delta") + } else { + s.Zero(rc.AttackBoost, "rc.AttackBoost must stay 0 when boost not applied") + } + + if tc.wantCross != "" { + s.InDelta(1.0, decideCounterValue(boostMetric, tc.wantCross), 0.0001, + "AttackBoostUsed{result=%q} should fire once", tc.wantCross) + } + }) + } +} + +func decideCounterValue(v *prometheus.CounterVec, label string) float64 { + c, err := v.GetMetricWithLabelValues(label) + if err != nil { + return 0 + } + + m := &dto.Metric{} + if err := c.Write(m); err != nil { + return 0 + } + + return m.GetCounter().GetValue() +} + func (s *DecideSuite) newEngine(captchaThreshold, blockThreshold float64) *Engine { return New(Config{ CaptchaThreshold: captchaThreshold, @@ -150,7 +232,7 @@ func (s *DecideSuite) newEngine(captchaThreshold, blockThreshold float64) *Engin CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, SoftBlockDuration: 10 * time.Minute, - }, storage.NewMemoryKV(100000), nil, nil, nil, nil, embedlog.NewLogger(false, false), testMetrics()) + }, storage.NewMemoryKV(100000), nil, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) } func (s *DecideSuite) requestWithScore(ipStr string, score float64) *http.Request { @@ -187,7 +269,7 @@ func BenchmarkDecidePass(b *testing.B) { CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, SoftBlockDuration: 10 * time.Minute, - }, storage.NewMemoryKV(100000), nil, nil, nil, nil, embedlog.NewLogger(false, false), testMetrics()) + }, storage.NewMemoryKV(100000), nil, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) handler := e.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) @@ -225,7 +307,7 @@ func BenchmarkDecidePlatformCheck(b *testing.B) { {Platform: "android", Captcha: true, MinVersion: [3]int{3, 0, 0}, Fallback: waf.ActionPass}, {Platform: "widget", Captcha: false, Fallback: waf.ActionPass}, }, - }, storage.NewMemoryKV(100000), nil, nil, nil, nil, embedlog.NewLogger(false, false), testMetrics()) + }, storage.NewMemoryKV(100000), nil, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) b.ResetTimer() b.ReportAllocs() @@ -455,7 +537,7 @@ func (s *DecideSuite) newPlatformEngine() *Engine { {Platform: "ios", Captcha: true, MinVersion: [3]int{2, 5, 0}, Fallback: waf.ActionPass}, {Platform: "android", Captcha: true, MinVersion: [3]int{3, 0, 0}, Fallback: waf.ActionPass}, }, - }, storage.NewMemoryKV(100000), nil, nil, nil, nil, embedlog.NewLogger(false, false), testMetrics()) + }, storage.NewMemoryKV(100000), nil, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) } func (s *DecideSuite) requestWithPlatformScore(platform, version string) *http.Request { diff --git a/internal/waf/filter/filter.go b/internal/waf/filter/filter.go index 8063977..988475b 100644 --- a/internal/waf/filter/filter.go +++ b/internal/waf/filter/filter.go @@ -20,6 +20,7 @@ const trafficFilterScore = 5.0 type TrafficRule struct { Name string // rule identifier Action string // "block" | "captcha" | "log" + AttackOnly bool // active only when Under Attack Mode is enabled IP []string // exact IP or CIDR UAExact []string // exact User-Agent match UAPrefix []string // User-Agent prefix match @@ -71,8 +72,9 @@ type RuleMatch struct { // Metrics holds traffic filter prometheus metrics. type Metrics struct { - MatchedTotal *prometheus.CounterVec // labels: rule, action - Recorder *event.Recorder + MatchedTotal *prometheus.CounterVec // labels: rule, action + AttackOnlyTotal *prometheus.CounterVec // labels: rule — fired AttackOnly rules + Recorder *event.Recorder } // MatchRequest holds extracted request fields for rule matching. @@ -95,18 +97,23 @@ type TrafficFilter struct { mu sync.RWMutex staticRules []TrafficRule dynamicRules []TrafficRule + attackState waf.AttackState embedlog.Logger metrics Metrics } -// New creates a new TrafficFilter with static rules from config. -func New(rules []TrafficRule, sl embedlog.Logger, metrics Metrics) *TrafficFilter { +// New creates a new TrafficFilter with static rules from config. attackState +// MUST be non-nil — read on each request that has AttackOnly rules. Pass +// dashboard.AttackService in production; a stub `IsEnabled() bool { return false }` +// works in tests. +func New(rules []TrafficRule, attackState waf.AttackState, sl embedlog.Logger, metrics Metrics) *TrafficFilter { for i := range rules { rules[i].Init() } return &TrafficFilter{ staticRules: rules, + attackState: attackState, Logger: sl, metrics: metrics, } @@ -240,11 +247,30 @@ func (f *TrafficFilter) Middleware() func(http.Handler) http.Handler { // evalRules evaluates rules, returns true if a block action fired. func (f *TrafficFilter) evalRules(r *http.Request, rc *waf.RequestContext, req MatchRequest, rules []TrafficRule) bool { + // Lazy: only query attack state if some rule actually needs it. Common + // case (no AttackOnly rules) avoids the dashboard.AttackService call entirely. + attackChecked, attackOn := false, false + for _, rule := range rules { + if rule.AttackOnly { + if !attackChecked { + attackOn = f.attackState.IsEnabled() + attackChecked = true + } + + if !attackOn { + continue + } + } + if !rule.isActive(req) { continue } + if rule.AttackOnly && f.metrics.AttackOnlyTotal != nil { + f.metrics.AttackOnlyTotal.WithLabelValues(rule.Name).Inc() + } + f.metrics.MatchedTotal.WithLabelValues(rule.Name, rule.Action).Inc() switch rule.Action { diff --git a/internal/waf/filter/filter_test.go b/internal/waf/filter/filter_test.go index 6e9eb13..a65ee36 100644 --- a/internal/waf/filter/filter_test.go +++ b/internal/waf/filter/filter_test.go @@ -1,9 +1,16 @@ package filter import ( + "net/http" + "net/http/httptest" "testing" + "time" + + "wafsrv/internal/waf" + "wafsrv/internal/waf/event" "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/suite" "github.com/vmkteam/embedlog" ) @@ -16,6 +23,14 @@ func TestFilter(t *testing.T) { suite.Run(t, new(FilterSuite)) } +// fakeAttackState is a controllable AttackState for tests. +type fakeAttackState struct{ on bool } + +func (f *fakeAttackState) IsEnabled() bool { return f.on } + +// attackOff returns an always-off AttackState (peace time). +func attackOff() waf.AttackState { return &fakeAttackState{on: false} } + func (s *FilterSuite) TestSingleField_UAPrefix() { r := TrafficRule{Name: "python", Action: "block", UAPrefix: []string{"Python/"}} s.True(r.isActive(MatchRequest{UA: "Python/3.9 aiohttp/3.8"})) @@ -105,6 +120,7 @@ func (s *FilterSuite) TestUAPrefix_SubstringNoMatch() { func (s *FilterSuite) TestDynamic_AddRemoveList() { f := New( []TrafficRule{{Name: "static-rule", Action: "block", UAPrefix: []string{"Python/"}}}, + attackOff(), embedlog.NewLogger(false, false), Metrics{MatchedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "test_total", Help: "test"}, []string{"rule", "action"})}, ) @@ -140,6 +156,7 @@ func (s *FilterSuite) TestTestRequest() { {Name: "python", Action: "block", UAPrefix: []string{"Python/"}}, {Name: "geo", Action: "captcha", Country: []string{"CN"}}, }, + attackOff(), embedlog.NewLogger(false, false), Metrics{MatchedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "test2_total", Help: "test"}, []string{"rule", "action"})}, ) @@ -304,3 +321,72 @@ func (s *FilterSuite) TestTripleField_AND() { s.False(r.isActive(MatchRequest{Platform: "Desktop", Version: "149bd482", Method: "GET"})) s.False(r.isActive(MatchRequest{Platform: "Mobile", Version: "149bd482", Method: "POST"})) } + +func (s *FilterSuite) TestAttackOnly_GatedByAttackState() { + cases := []struct { + name string + attackOn bool + path string + wantCode int + }{ + {"attack off, attack-only path → pass", false, "/search/all", http.StatusOK}, + {"attack on, attack-only path → block", true, "/search/all", http.StatusForbidden}, + {"attack off, regular always-on rule still blocks", false, "/admin", http.StatusForbidden}, + {"attack on, regular always-on rule still blocks", true, "/admin", http.StatusForbidden}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + st := &fakeAttackState{on: tc.attackOn} + attackOnlyTotal := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "test_attack_only_total", Help: "t"}, []string{"rule"}) + matched := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "test_matched_" + tc.name, Help: "t"}, []string{"rule", "action"}) + + f := New( + []TrafficRule{ + {Name: "always-admin", Action: "block", Path: []string{"/admin"}}, + {Name: "attack-search", Action: "block", AttackOnly: true, Path: []string{"/search/"}}, + }, + st, + embedlog.NewLogger(false, false), + Metrics{ + MatchedTotal: matched, + AttackOnlyTotal: attackOnlyTotal, + Recorder: event.NewRecorder(event.NewBuffer(1), event.NewSeries(time.Second, 1), event.NewTops(time.Minute, 1)), + }, + ) + + h := f.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req = req.WithContext(waf.NewContext(req.Context(), &waf.RequestContext{})) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + + // AttackOnly counter must reflect only AttackOnly fires + got := testutilCounterValue(attackOnlyTotal, "attack-search") + if tc.attackOn && tc.path == "/search/all" { + s.InDelta(1.0, got, 0.0001, "AttackOnlyTotal should fire for matched attack-only rule") + } else { + s.InDelta(0.0, got, 0.0001, "AttackOnlyTotal must not fire when rule does not match or attack off") + } + }) + } +} + +func testutilCounterValue(v *prometheus.CounterVec, label string) float64 { + c, err := v.GetMetricWithLabelValues(label) + if err != nil { + return 0 + } + + m := &dto.Metric{} + if err := c.Write(m); err != nil { + return 0 + } + + return m.GetCounter().GetValue() +} diff --git a/internal/waf/model.go b/internal/waf/model.go index abfaefd..87fd910 100644 --- a/internal/waf/model.go +++ b/internal/waf/model.go @@ -2,6 +2,13 @@ package waf import "net/netip" +// AttackState exposes Under Attack Mode status to consumers (filter, decide). +// Lives in this package to avoid filter/decide depending on the dashboard +// package that owns the production implementation (dashboard.AttackService). +type AttackState interface { + IsEnabled() bool +} + // Action represents a WAF decision action. type Action int @@ -49,6 +56,7 @@ type RequestContext struct { RPC *RPCCall IP *IPInfo // filled by ip.Middleware WAFScore float64 // filled by engine.Middleware + AttackBoost float64 // filled by decide.Middleware when AttackScoreBoost lifted score in attack mode Decision Action // filled by decide.Middleware TrafficType string // filled by sign.Middleware Target string // filled by proxy handler (backend URL)