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 @@
+N to WAFScore while attack mode is on. Must be < Decision.CaptchaThreshold.