diff --git a/README.md b/README.md index b9183da..1cd8455 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Designed to run as a Docker sidecar in front of your backend service (behind Ngi - Search engine bot verification (Google, Bing, Yandex, Apple, DuckDuckGo) via reverse DNS + IP ranges - Traffic filter rules (User-Agent, IP, country, platform, ASN, RPC method — AND semantics) - Request signing (HMAC-SHA256) — anti-replay, anti-tampering -- Decision engine: cumulative score -> pass / CAPTCHA (499) / block (403) +- Decision engine: cumulative score -> pass / CAPTCHA (403, `X-WAF-Action: captcha`) / block (403, `X-WAF-Action: block`); throttle (429, `X-WAF-Action: throttle`) for rate-limit - CAPTCHA challenge pages: [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), [hCaptcha](https://www.hcaptcha.com/), or built-in Proof-of-Work (SHA-256) - Per-platform CAPTCHA policy with version gating and fallback actions - Captcha pass cache: HMAC cookie + IP fallback diff --git a/cfg/local.toml.dist b/cfg/local.toml.dist index 7d68d3d..2f94073 100644 --- a/cfg/local.toml.dist +++ b/cfg/local.toml.dist @@ -207,13 +207,19 @@ Listen = "127.0.0.1:8081" # === CAPTCHA === # [Captcha] -# Provider = "turnstile" # "turnstile" (Cloudflare) | "hcaptcha" +# Provider = "turnstile" # "turnstile" (Cloudflare) | "hcaptcha" | "pow" +# Secret = "" # REQUIRED when Provider is set. +# # HMAC key for waf_pass cookie + PoW challenge signature. +# # Generate: openssl rand -hex 32 +# # MUST be identical on every instance behind the LB, +# # otherwise cookies issued by one node won't validate on another. # SiteKey = "" # SecretKey = "" # hCaptcha test keys (no registration, always passes, no anti-bot protection): # [Captcha] # Provider = "hcaptcha" +# Secret = "change-me-generate-via-openssl-rand-hex-32" # SiteKey = "10000000-ffff-ffff-ffff-000000000001" # SecretKey = "0x0000000000000000000000000000000000000000" diff --git a/e2e/captcha_test.go b/e2e/captcha_test.go new file mode 100644 index 0000000..16c76b3 --- /dev/null +++ b/e2e/captcha_test.go @@ -0,0 +1,194 @@ +package e2e + +import ( + "net/http" + "strconv" + "strings" + + "wafsrv/internal/waf" +) + +// --- 07: Captcha (decide) --- +// +// Captcha is triggered via TrafficFilter rule "e2e-captcha-bot" (Action=captcha, +// +5 score) at CaptchaThreshold=5. Each test uses a distinct UA so the +// per-discriminator escalation counter (CaptchaToBlock=3) stays isolated. +// +// Covers commit 27f19a2 (Migrate captcha status code from 499 to 403): +// - captcha returns 403 (was 499) +// - captcha sets X-WAF-Action: captcha (disambiguates from block at same status) +// - captcha never sets Retry-After (semantics: solve, not wait) +// - block / soft_block / throttle DO set X-WAF-Action and Retry-After +// - Decision is set in middlewares so isBlocked metric reads from context + +func (s *E2ESuite) Test07_Captcha_Headers() { + req, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + req.Header.Set("User-Agent", "E2ECaptchaBot/1.0") + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + defer resp.Body.Close() + + s.Equal(http.StatusForbidden, resp.StatusCode, "captcha must return 403 (not 499)") + s.Equal(waf.ActionHeaderCaptcha, resp.Header.Get(waf.HeaderAction), + "captcha must set X-WAF-Action: captcha to disambiguate from block (both 403)") + s.Empty(resp.Header.Get("Retry-After"), + "captcha must NOT set Retry-After (solve, not wait)") +} + +func (s *E2ESuite) Test07_Captcha_Body() { + req, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + req.Header.Set("User-Agent", "E2ECaptchaBody/1.0") + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + defer resp.Body.Close() + + body := s.readBody(resp) + s.Equal(http.StatusForbidden, resp.StatusCode) + // no Captcha.Provider configured in e2e.toml → falls back to plain "Captcha required" text + s.Contains(body, "Captcha required", + "unconfigured-provider captcha falls back to http.Error") +} + +func (s *E2ESuite) Test07_Captcha_PerRequestIsolated() { + req, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + req.Header.Set("User-Agent", "E2ECaptchaBot/1.0") + + for i := range 2 { + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + s.Equal(http.StatusForbidden, resp.StatusCode, "request %d: captcha", i+1) + s.Equal(waf.ActionHeaderCaptcha, resp.Header.Get(waf.HeaderAction)) + resp.Body.Close() + } +} + +// Test07_Captcha_EscalationToSoftBlock walks through the captcha→soft_block +// transition end-to-end and asserts the composite key isolates discriminators: +// +// requests 1..3 with the captcha UA → 403 / X-WAF-Action: captcha +// on request 3 the engine reaches CaptchaToBlock=3 and arms BlockedUntil +// request 4 (same UA → same discriminator) → 403 / X-WAF-Action: block +// with Retry-After equal to the remaining SoftBlockDuration +// a sibling UA from the same IP must NOT inherit the soft_block — +// the (IP:Platform:fnv32(UA)) key is what makes wafsrv NAT-aware +func (s *E2ESuite) Test07_Captcha_EscalationToSoftBlock() { + req, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + req.Header.Set("User-Agent", "E2ECapEsc/1.0") + + // CaptchaToBlock = 3 in e2e.toml — drive the counter to escalation. + for i := range 3 { + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + s.Equal(http.StatusForbidden, resp.StatusCode, "captcha #%d", i+1) + s.Equal(waf.ActionHeaderCaptcha, resp.Header.Get(waf.HeaderAction), + "escalation step %d must still render captcha", i+1) + s.Empty(resp.Header.Get("Retry-After"), + "captcha must not leak Retry-After mid-escalation") + resp.Body.Close() + } + + // Next request with the same discriminator must be soft-blocked. + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + + s.Equal(http.StatusForbidden, resp.StatusCode, "post-escalation must 403") + s.Equal(waf.ActionHeaderBlock, resp.Header.Get(waf.HeaderAction), + "soft_block must set X-WAF-Action: block (clients can't tell from status)") + + retryAfter := resp.Header.Get("Retry-After") + s.NotEmpty(retryAfter, "soft_block must surface remaining TTL via Retry-After") + + secs, err := strconv.Atoi(retryAfter) + s.Require().NoError(err, "Retry-After must be integer seconds") + s.Positive(secs, "remaining TTL must be positive") + // SoftBlockDuration=1m → cap at 60 (rounding may inflate by 1) + s.LessOrEqual(secs, 61, "Retry-After must not exceed SoftBlockDuration") + resp.Body.Close() + + // Composite-key isolation: a different UA from the same IP must pass. + // Without the discriminator in the soft-block key, the whole NAT'd IP + // would lock out behind one bad client. + other, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + other.Header.Set("User-Agent", "E2EUnrelatedClient/1.0") // not matched by captcha rule + + resp, err = http.DefaultClient.Do(other) + s.Require().NoError(err) + defer resp.Body.Close() + s.Equal(http.StatusOK, resp.StatusCode, + "sibling UA on same IP must not inherit soft_block (composite key isolates discriminators)") +} + +func (s *E2ESuite) Test07_Captcha_ZZ_Metrics() { + // Hit captcha once with a fresh UA so the test is self-contained even when + // previous test ordering changes (escalation test may have already moved + // captcha counter forward but metric value is what we assert). + req, _ := http.NewRequest(http.MethodGet, dataURL+"/", nil) + req.Header.Set("User-Agent", "E2ECaptchaMetrics/1.0") + resp, err := http.DefaultClient.Do(req) + s.Require().NoError(err) + resp.Body.Close() + + body := s.getMetrics() + s.Contains(body, `wafsrv_decision_total{action="captcha"`, + "decision metric must expose captcha action label") +} + +// --- 03b: Throttle response shape --- + +// Test03_RateLimit_ThrottleHeader extends Test03_RateLimit_PerMethod by asserting +// the X-WAF-Action: throttle header introduced alongside the captcha→403 migration. +// Uses pgd.diff (sibling rule, fresh counter) so the assertion does not depend on +// Test03_RateLimit_PerMethod's execution order. +func (s *E2ESuite) Test03_RateLimit_ThrottleHeader() { + for range 5 { + resp := s.postRPC(dataURL+"/rpc/", "pgd.diff", "{}") + resp.Body.Close() + } + + resp := s.postRPC(dataURL+"/rpc/", "pgd.diff", "{}") + defer resp.Body.Close() + + s.Equal(http.StatusTooManyRequests, resp.StatusCode) + s.Equal(waf.ActionHeaderThrottle, resp.Header.Get(waf.HeaderAction), + "429 must set X-WAF-Action: throttle to disambiguate from block/captcha 403") + s.NotEmpty(resp.Header.Get("Retry-After"), + "throttle must include Retry-After (RateLimit.RetryAfter, default 60s)") +} + +// --- 04b: Block response shape --- + +// Test04_IP_Block_HeaderAndRetryAfter extends Test04_IP_BlockAndUnblock by +// asserting the new X-WAF-Action: block header (added when both captcha and +// block returned 403, status alone became ambiguous) and that a TTL'd +// blacklist exposes the remaining duration via Retry-After. +func (s *E2ESuite) Test04_IP_Block_HeaderAndRetryAfter() { + clientIP := s.detectClientIP() + + result := s.mgmtRPC("block.add", + `{"blockType":"ip","value":"`+clientIP+`","reason":"e2e-headers","duration":"30s"}`) + s.Contains(result, `"result":true`) + + resp := s.get(dataURL + "/") + defer resp.Body.Close() + defer s.mgmtRPC("block.remove", `{"blockType":"ip","value":"`+clientIP+`"}`) + + s.Equal(http.StatusForbidden, resp.StatusCode) + s.Equal(waf.ActionHeaderBlock, resp.Header.Get(waf.HeaderAction), + "static/runtime block must set X-WAF-Action: block") + + retryAfter := resp.Header.Get("Retry-After") + s.NotEmpty(retryAfter, + "TTL'd blacklist entry must surface remaining duration via Retry-After") + + // duration "30s" was just set; allow a couple of seconds of drift + secs, err := strconv.Atoi(retryAfter) + s.Require().NoError(err) + s.Positive(secs) + s.LessOrEqual(secs, 31) + + // body should be the block error text (RenderBlock isn't used here — ip middleware + // uses http.Error). Sanity check we didn't somehow render the captcha body. + body := s.readBody(resp) + s.NotContains(strings.ToLower(body), "captcha required", + "block path must not render captcha body") +} diff --git a/e2e/cfg/e2e-aerospike.toml b/e2e/cfg/e2e-aerospike.toml index 95774a4..cd10fda 100644 --- a/e2e/cfg/e2e-aerospike.toml +++ b/e2e/cfg/e2e-aerospike.toml @@ -47,6 +47,11 @@ Name = "e2e-log-bot" Action = "log" UAPrefix = ["E2ELogBot/"] +[[TrafficFilter.Rules]] +Name = "e2e-captcha-bot" +Action = "captcha" +UAPrefix = ["E2ECaptchaBot/", "E2ECaptchaBody/", "E2ECaptchaMetrics/", "E2ECapEsc/"] + [Signing] Enabled = true Mode = "detection" diff --git a/e2e/cfg/e2e.toml b/e2e/cfg/e2e.toml index f98342c..46a35ea 100644 --- a/e2e/cfg/e2e.toml +++ b/e2e/cfg/e2e.toml @@ -53,6 +53,11 @@ AttackOnly = true Action = "block" UAPrefix = ["E2EAttackBot/"] +[[TrafficFilter.Rules]] +Name = "e2e-captcha-bot" +Action = "captcha" +UAPrefix = ["E2ECaptchaBot/", "E2ECaptchaBody/", "E2ECaptchaMetrics/", "E2ECapEsc/"] + [Signing] Enabled = true Mode = "detection" diff --git a/internal/app/app.go b/internal/app/app.go index c081c9a..fa29567 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -112,6 +112,7 @@ func New(appName string, sl embedlog.Logger, cfg Config) (*App, error) { // AttackService must exist before WAF wire-up: filter and decide read it via waf.AttackState DI. a.attackSvc = dashboard.NewAttackService() + a.metrics.registerAttackModeGauge(a.attackSvc.IsEnabled) if err := a.initWAF(); err != nil { return nil, err @@ -300,6 +301,7 @@ func (a *App) initRPCInspector() { func (a *App) initDecision() { // captcha cache a.captchaCache = challenge.NewCache(challenge.CacheConfig{ + Secret: []byte(a.cfg.Captcha.Secret), CookieName: a.cfg.Captcha.CookieName, CookieTTL: parseDuration(a.cfg.Captcha.CookieTTL, 30*time.Minute), IPCacheTTL: parseDuration(a.cfg.Captcha.IPCacheTTL, 30*time.Minute), @@ -369,6 +371,7 @@ func (a *App) initDecision() { CaptchaToBlock: a.cfg.Decision.CaptchaToBlock, CaptchaToBlockWindow: parseDuration(a.cfg.Decision.CaptchaToBlockWindow, 10*time.Minute), SoftBlockDuration: parseDuration(a.cfg.Decision.SoftBlockDuration, 10*time.Minute), + BlockRetryAfter: parseDuration(a.cfg.Decision.BlockRetryAfter, 0), CaptchaProvider: a.cfg.Captcha.Provider, CaptchaSiteKey: a.cfg.Captcha.SiteKey, CaptchaSecretKey: a.cfg.Captcha.SecretKey, diff --git a/internal/app/config.go b/internal/app/config.go index ed6c406..358f17f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -3,6 +3,7 @@ package app import ( "errors" "fmt" + "log/slog" "net/http" "net/url" "time" @@ -129,11 +130,12 @@ func (c *TrafficFilterConfig) TrafficFilterEnabled() bool { type DecisionConfig struct { CaptchaThreshold float64 // default 5 BlockThreshold float64 // default 8 - CaptchaStatusCode int // default 499 + CaptchaStatusCode int // default 403 BlockStatusCode int // default 403 CaptchaToBlock int // default 3 CaptchaToBlockWindow string // default "10m" SoftBlockDuration string // default "10m" + BlockRetryAfter string // duration, default "" = no Retry-After header on block responses CaptchaFallback string // default "block"; "pass" | "block" | "log" Platforms []PlatformCaptchaConfig } @@ -148,7 +150,12 @@ type PlatformCaptchaConfig struct { // CaptchaConfig configures the CAPTCHA provider. type CaptchaConfig struct { - Provider string // "turnstile" | "hcaptcha" | "pow" + Provider string // "turnstile" | "hcaptcha" | "pow" + // Secret is the HMAC key used to sign waf_pass cookies and PoW challenges. + // Required when Provider != "". MUST be identical on every instance behind + // a load balancer, otherwise cookies issued by one instance won't validate + // on another. Generate via `openssl rand -hex 32` (>= 32 bytes). + Secret string SiteKey string SecretKey string CookieName string // default "waf_pass" @@ -210,6 +217,7 @@ type RateLimitConfig struct { PerIP string // "100/min" Action string // "block" | "throttle" MaxCounters int // LRU eviction + RetryAfter string // duration, default "60s"; sent as Retry-After on 429 Rules []RateLimitRule // RPC method matching URLRules []URLRateLimitRule // HTTP path/method/host matching } @@ -501,6 +509,8 @@ func (c *Config) Validate() error { {c.Captcha.IPCacheTTL, "Captcha.IPCacheTTL"}, {c.Decision.CaptchaToBlockWindow, "Decision.CaptchaToBlockWindow"}, {c.Decision.SoftBlockDuration, "Decision.SoftBlockDuration"}, + {c.Decision.BlockRetryAfter, "Decision.BlockRetryAfter"}, + {c.RateLimit.RetryAfter, "RateLimit.RetryAfter"}, {c.IP.Reputation.UpdateInterval, "IP.Reputation.UpdateInterval"}, } @@ -528,9 +538,20 @@ func (c *Config) Validate() error { return err } + c.warnMultiInstance() + return nil } +// warnMultiInstance flags the in-memory storage backend as a multi-instance hazard. +func (c *Config) warnMultiInstance() { + if c.Captcha.Provider != "" && c.Storage.Backend == StorageMemory { + slog.Default().Warn("captcha enabled with memory storage backend", + "impact", "IP fallback, escalation counters and signing nonces won't be shared between instances", + "fix", `set Storage.Backend = "aerospike" for multi-instance deployments`) + } +} + // 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. @@ -584,11 +605,23 @@ func (c *RateLimitConfig) validate() error { var validCaptchaProviders = map[string]bool{"turnstile": true, "hcaptcha": true, "pow": true, "": true} +const minCaptchaSecretBytes = 32 + func (c *CaptchaConfig) validate() error { if !validCaptchaProviders[c.Provider] { return fmt.Errorf("config: Captcha.Provider must be turnstile, hcaptcha or pow, got %q", c.Provider) } + if c.Provider != "" && c.Secret == "" { + return errors.New("config: Captcha.Secret is required when Captcha.Provider is set " + + "(generate via `openssl rand -hex 32`, must be identical on all instances behind a load balancer)") + } + + if c.Provider != "" && len(c.Secret) < minCaptchaSecretBytes { + return fmt.Errorf("config: Captcha.Secret must be at least %d bytes long (got %d)", + minCaptchaSecretBytes, len(c.Secret)) + } + if err := validateDuration(c.PoW.Timeout, "Captcha.PoW.Timeout"); err != nil { return err } @@ -779,6 +812,7 @@ func (c *Config) LimiterConfig() (limit.Config, error) { PerIP: perIP, Action: c.RateLimit.Action, MaxCounters: c.RateLimit.MaxCounters, + RetryAfter: parseDuration(c.RateLimit.RetryAfter, 60*time.Second), Rules: rules, URLRules: urlRules, }, nil @@ -1030,6 +1064,10 @@ func applyWAFDefaults(c *Config) { c.RateLimit.MaxCounters = 100000 } + if c.RateLimit.RetryAfter == "" { + c.RateLimit.RetryAfter = "60s" + } + if c.IP.Whitelist.BotVerify.CacheSize == 0 { c.IP.Whitelist.BotVerify.CacheSize = 10000 } @@ -1075,7 +1113,7 @@ func applyDecisionDefaults(c *Config) { } if c.Decision.CaptchaStatusCode == 0 { - c.Decision.CaptchaStatusCode = 499 + c.Decision.CaptchaStatusCode = http.StatusForbidden } if c.Decision.BlockStatusCode == 0 { diff --git a/internal/app/config_test.go b/internal/app/config_test.go index c21402e..a930821 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -288,6 +288,65 @@ func (s *ConfigSuite) TestInvalidTOML() { s.Error(err, "should fail on invalid TOML") } +func (s *ConfigSuite) TestCaptchaSecretNotRequiredWhenDisabled() { + path := s.writeConfig(` +[Proxy] +Targets = ["http://localhost:3000"] +ServiceName = "test-svc" +`) + + _, err := LoadConfig(path) + s.Require().NoError(err, "captcha disabled (Provider == \"\") must not require Secret") +} + +func (s *ConfigSuite) TestCaptchaSecretRequiredWhenProviderSet() { + path := s.writeConfig(` +[Proxy] +Targets = ["http://localhost:3000"] +ServiceName = "test-svc" + +[Captcha] +Provider = "pow" +`) + + _, err := LoadConfig(path) + s.Require().Error(err, "Captcha.Provider set without Secret must fail") + s.Contains(err.Error(), "Captcha.Secret is required") +} + +func (s *ConfigSuite) TestCaptchaSecretTooShort() { + path := s.writeConfig(` +[Proxy] +Targets = ["http://localhost:3000"] +ServiceName = "test-svc" + +[Captcha] +Provider = "turnstile" +Secret = "short" +`) + + _, err := LoadConfig(path) + s.Require().Error(err, "secret shorter than 32 bytes must fail") + s.Contains(err.Error(), "at least 32 bytes") +} + +func (s *ConfigSuite) TestCaptchaSecretValid() { + path := s.writeConfig(` +[Proxy] +Targets = ["http://localhost:3000"] +ServiceName = "test-svc" + +[Captcha] +Provider = "pow" +Secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +`) + + cfg, err := LoadConfig(path) + s.Require().NoError(err, "valid 64-hex secret with provider must pass validation") + s.Equal("pow", cfg.Captcha.Provider) + s.Len(cfg.Captcha.Secret, 64) +} + func (s *ConfigSuite) writeConfig(content string) string { dir := s.T().TempDir() path := filepath.Join(dir, "test.toml") diff --git a/internal/app/config_view.go b/internal/app/config_view.go index 5bbd57c..c192f38 100644 --- a/internal/app/config_view.go +++ b/internal/app/config_view.go @@ -14,7 +14,7 @@ func BuildConfigResponse(c Config) dashboard.ConfigResponse { 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}, + Captcha: dashboard.CaptchaSection{Provider: c.Captcha.Provider, HasKeys: c.Captcha.SiteKey != "" && c.Captcha.SecretKey != "", HasSecret: c.Captcha.Secret != "", CookieName: c.Captcha.CookieName, CookieTTL: c.Captcha.CookieTTL, IPCacheTTL: c.Captcha.IPCacheTTL}, Alerting: dashboard.AlertingSection{Enabled: c.Alerting.AlertingEnabled(), WebhookCount: len(c.Alerting.Webhooks)}, Adaptive: buildAdaptiveSection(c), Storage: dashboard.StorageSection{Backend: c.Storage.Backend, Hosts: len(c.Storage.Aerospike.Hosts), Namespace: c.Storage.Aerospike.Namespace, KeyPrefix: c.Storage.Aerospike.KeyPrefix}, diff --git a/internal/app/metrics.go b/internal/app/metrics.go index bf18dcf..445d433 100644 --- a/internal/app/metrics.go +++ b/internal/app/metrics.go @@ -31,7 +31,8 @@ const ( metricAdaptiveAttack = "wafsrv_adaptive_attack_total" metricProxyErrorsTotal = "wafsrv_proxy_errors_total" metricAttackOnlyMatch = "wafsrv_attack_only_match_total" - metricAttackScoreBoost = "wafsrv_attack_score_boost_applied_total" + metricAttackScoreBoost = "wafsrv_attack_score_boost_total" + metricAttackModeActive = "wafsrv_attack_mode_active" ) // appMetrics holds all prometheus metrics for the application. @@ -155,12 +156,12 @@ func newMetrics() *appMetrics { //nolint:funlen // flat constructor, no logic to attackOnlyTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ Name: metricAttackOnlyMatch, - Help: "Total fires of AttackOnly traffic-filter rules (Under Attack Mode).", + Help: "Total fires of AttackOnly traffic-filter rules during Under Attack Mode. Strict subset of wafsrv_traffic_filter_total{rule} — AttackOnly fires also bump the regular filter counter.", }, []string{"rule"}), attackScoreBoostUsed: prometheus.NewCounterVec(prometheus.CounterOpts{ Name: metricAttackScoreBoost, - Help: "Total requests where AttackScoreBoost moved the decision (captcha/block) during Under Attack Mode.", + Help: "Total requests where AttackScoreBoost moved the decision across a threshold (captcha/block) during Under Attack Mode. Counts threshold crossings only, not every boosted request.", }, []string{"result"}), } @@ -186,6 +187,30 @@ func newMetrics() *appMetrics { //nolint:funlen // flat constructor, no logic to return m } +// registerAttackModeGauge wires a GaugeFunc that returns 1 while Under Attack +// Mode is active. Must be called once after the AttackService is constructed. +// The gauge unlocks operator queries like +// +// max_over_time(wafsrv_attack_mode_active[1h]) // was attack mode on at all +// changes(wafsrv_attack_mode_active[1h]) // toggle count for timelines +// +// IsEnabled is the lock-free atomic read added in the snapshot refactor, so +// the GaugeFunc is safe to call on every Prometheus scrape. +func (m *appMetrics) registerAttackModeGauge(isEnabled func() bool) { + g := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: metricAttackModeActive, + Help: "1 if Under Attack Mode is currently enabled, 0 otherwise.", + }, func() float64 { + if isEnabled() { + return 1 + } + + return 0 + }) + + m.registry.MustRegister(g) +} + // proxyErrorRecorder adapts the proxy_errors_total counter to proxy.ErrorRecorder. type proxyErrorRecorder struct { v *prometheus.CounterVec diff --git a/internal/app/middleware.go b/internal/app/middleware.go index 724a0bf..fcd2b9f 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -113,8 +113,7 @@ func recordAccessTops(recorder *event.Recorder, r *http.Request, rec *statusReco country = rc.IP.Country } - isBlocked := rec.status == http.StatusForbidden || rec.status == http.StatusTooManyRequests || rec.status == 499 - recorder.RecordTops(rc.ClientIP.String(), r.URL.Path, country, rc.Platform, isBlocked) + recorder.RecordTops(rc.ClientIP.String(), r.URL.Path, country, rc.Platform, isBlockedDecision(rc.Decision)) if ua := r.UserAgent(); ua != "" { recorder.RecordUA(ua) @@ -534,6 +533,17 @@ func computeDiscriminator(platform, ua string) string { return platform + ":" + strconv.FormatUint(uint64(h.Sum32()), 16) } +// isBlockedDecision reports whether a decision counts as "blocked" for top-N +// metrics. Captcha is excluded — it's a challenge, not a denial. +func isBlockedDecision(d waf.Action) bool { + switch d { //nolint:exhaustive // ActionPass/Log/Captcha are intentionally not blocks + case waf.ActionBlock, waf.ActionHardBlock, waf.ActionSoftBlock, waf.ActionThrottle: + return true + default: + return false + } +} + func parseFallbackAction(s string) waf.Action { switch s { case "block": diff --git a/internal/dashboard/config.go b/internal/dashboard/config.go index ab2959a..2bdb09d 100644 --- a/internal/dashboard/config.go +++ b/internal/dashboard/config.go @@ -177,6 +177,7 @@ type PlatformCaptchaInfo struct { type CaptchaSection struct { Provider string `json:"provider,omitempty"` HasKeys bool `json:"hasKeys"` + HasSecret bool `json:"hasSecret"` CookieName string `json:"cookieName"` CookieTTL string `json:"cookieTtl"` IPCacheTTL string `json:"ipCacheTtl"` diff --git a/internal/dashboard/dashboard_zenrpc.go b/internal/dashboard/dashboard_zenrpc.go index 90ef7b6..e2e18d1 100644 --- a/internal/dashboard/dashboard_zenrpc.go +++ b/internal/dashboard/dashboard_zenrpc.go @@ -1154,7 +1154,7 @@ func (ConfigService) SMD() smd.ServiceInfo { }, { Name: "scoreBoost", - Description: `additive WAFScore bonus during attack mode (Decision.AttackScoreBoost)`, + Description: `additive WAFScore bonus during attack mode (Adaptive.AutoAttack.ScoreBoost)`, Type: smd.Float, }, { diff --git a/internal/dashboard/web/builder/builder-config.js b/internal/dashboard/web/builder/builder-config.js index 151ea46..9044922 100644 --- a/internal/dashboard/web/builder/builder-config.js +++ b/internal/dashboard/web/builder/builder-config.js @@ -50,12 +50,12 @@ function builderConfig() { }, decision: { captchaThreshold: 5, blockThreshold: 8, - captchaStatusCode: 499, blockStatusCode: 403, + captchaStatusCode: 403, blockStatusCode: 403, captchaToBlock: 3, captchaToBlockWindow: '10m', softBlockDuration: '10m', captchaFallback: 'block', platforms: [], }, - captcha: {provider: '', siteKey: '', secretKey: '', cookieName: 'waf_pass', cookieTTL: '30m', ipCacheTTL: '30m', + captcha: {provider: '', secret: '', siteKey: '', secretKey: '', cookieName: 'waf_pass', cookieTTL: '30m', ipCacheTTL: '30m', pow: {difficulty: 50000, attackDifficulty: 500000, timeout: '10s', saltTTL: '5m'}}, alerting: {enabled: false, webhooks: []}, adaptive: { diff --git a/internal/dashboard/web/builder/builder-import.js b/internal/dashboard/web/builder/builder-import.js index e995b96..67dc5ae 100644 --- a/internal/dashboard/web/builder/builder-import.js +++ b/internal/dashboard/web/builder/builder-import.js @@ -338,6 +338,7 @@ function builderImport() { // Captcha if (t.Captcha) { if (t.Captcha.Provider) cfg.captcha.provider = t.Captcha.Provider; + if (t.Captcha.Secret) cfg.captcha.secret = t.Captcha.Secret; if (t.Captcha.SiteKey) cfg.captcha.siteKey = t.Captcha.SiteKey; if (t.Captcha.SecretKey) cfg.captcha.secretKey = t.Captcha.SecretKey; if (t.Captcha.CookieName) cfg.captcha.cookieName = t.Captcha.CookieName; diff --git a/internal/dashboard/web/builder/builder-toml.js b/internal/dashboard/web/builder/builder-toml.js index 471bf06..2e4b873 100644 --- a/internal/dashboard/web/builder/builder-toml.js +++ b/internal/dashboard/web/builder/builder-toml.js @@ -384,6 +384,10 @@ function builderToml() { lines.push(''); lines.push('[Captcha]'); lines.push(`Provider = ${this._q(c.captcha.provider)}`); + // HMAC secret for waf_pass cookie + PoW signing — required for any provider + if (c.captcha.secret) { + lines.push(`Secret = ${this._q(c.captcha.secret)}`); + } // SiteKey/SecretKey only for turnstile/hcaptcha if (c.captcha.provider !== 'pow') { if (c.captcha.siteKey) lines.push(`SiteKey = ${this._q(c.captcha.siteKey)}`); diff --git a/internal/dashboard/web/builder/index.html b/internal/dashboard/web/builder/index.html index ab4e16f..3e5936e 100644 --- a/internal/dashboard/web/builder/index.html +++ b/internal/dashboard/web/builder/index.html @@ -722,7 +722,7 @@

Platform Captcha Policy

Captcha - +

CAPTCHA provider for the decision engine. Turnstile: free, invisible. hCaptcha: GDPR, 1M/month free. PoW: built-in SHA-256, no API keys, self-hosted. After passing, client gets HMAC cookie (waf_pass). Native apps use IP cache fallback.

@@ -739,6 +739,13 @@

Platform Captcha Policy

+ +
+ + + Signs waf_pass cookie + PoW challenges. Must match across instances behind the LB. +
+
diff --git a/internal/dashboard/web/index.html b/internal/dashboard/web/index.html index 0639faf..0a92684 100644 --- a/internal/dashboard/web/index.html +++ b/internal/dashboard/web/index.html @@ -106,11 +106,11 @@

Protection Pipeline

WAF Matches
Detection
-
+
—
CAPTCHA
-
499
+
X-WAF-Action: captcha
—
@@ -742,10 +742,11 @@

Platform Captcha Policy

- Captcha + Captcha
Provider
-
Keys Configured
+
HMAC Secret
+
Provider Keys
Cookie Name
Cookie TTL
IP Cache TTL
diff --git a/internal/waf/challenge/cache.go b/internal/waf/challenge/cache.go index 25cb9db..b425f70 100644 --- a/internal/waf/challenge/cache.go +++ b/internal/waf/challenge/cache.go @@ -31,6 +31,8 @@ type CacheConfig struct { // NewCache creates a new captcha pass cache. func NewCache(cfg CacheConfig, store storage.KVStore) *Cache { + // Production: unreachable — app/config.go enforces Captcha.Secret when + // Provider != "". Kept for tests and embedded usage that bypass TOML config. if len(cfg.Secret) == 0 { cfg.Secret = []byte("wafsrv-default-secret-change-me") } diff --git a/internal/waf/challenge/pages.go b/internal/waf/challenge/pages.go index aded627..ef1a458 100644 --- a/internal/waf/challenge/pages.go +++ b/internal/waf/challenge/pages.go @@ -36,7 +36,7 @@ var providers = map[string]providerWidget{ }, } -// RenderCaptcha writes a captcha challenge page (HTTP 499). +// RenderCaptcha writes a captcha challenge page (HTTP 403 by default). func RenderCaptcha(w http.ResponseWriter, statusCode int, provider, siteKey, cookieName, requestID string, branding Branding) { title := branding.Title if title == "" { @@ -94,7 +94,7 @@ function onPass(token){ `, title, color, pw.ScriptURL, title, pw.DivClass, siteKey, requestID, cookieName) } -// RenderPowCaptcha writes a PoW challenge page (HTTP 499). +// RenderPowCaptcha writes a PoW challenge page (HTTP 403 by default). func RenderPowCaptcha(w http.ResponseWriter, statusCode int, ch PowChallenge, cookieName, requestID string, timeout time.Duration, branding Branding) { title := branding.Title if title == "" { diff --git a/internal/waf/decide/decide.go b/internal/waf/decide/decide.go index 3589b92..d80636c 100644 --- a/internal/waf/decide/decide.go +++ b/internal/waf/decide/decide.go @@ -30,7 +30,8 @@ type Config struct { CaptchaToBlock int CaptchaToBlockWindow time.Duration SoftBlockDuration time.Duration - CaptchaProvider string // "turnstile" | "hcaptcha" | "pow" + BlockRetryAfter time.Duration // 0 = no Retry-After header on block responses + CaptchaProvider string // "turnstile" | "hcaptcha" | "pow" CaptchaSiteKey string CaptchaSecretKey string CaptchaCookieName string @@ -92,7 +93,7 @@ func New(cfg Config, store storage.KVStore, cache *challenge.Cache, verifier *ch } // Middleware returns an HTTP middleware that applies decisions based on score. -func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocognit +func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocognit,funlen return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rc := waf.FromContext(r.Context()) @@ -118,12 +119,14 @@ func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocogni } // check soft block (per discriminator — NAT-aware) - if e.isSoftBlocked(eKey) { + if blocked, remaining := e.isSoftBlocked(eKey); blocked { rc.Decision = waf.ActionSoftBlock e.metrics.DecisionTotal.WithLabelValues("soft_block", e.metricPlatform(rc.Platform)).Inc() e.metrics.Recorder.RecordDecision("soft_block") e.addEvent(r.Context(), rc.ClientIP.String(), r.URL.Path, "soft_block") e.metrics.Recorder.RecordBlocked(rc.ClientIP.String(), r.URL.Path, "", rc.Platform) + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) + setRetryAfter(w, remaining) challenge.RenderBlock(w, e.cfg.BlockStatusCode, rc.RequestID, e.cfg.Branding) return @@ -166,6 +169,8 @@ func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocogni e.addEvent(r.Context(), rc.ClientIP.String(), r.URL.Path, "block") e.metrics.Recorder.RecordBlocked(rc.ClientIP.String(), r.URL.Path, "", rc.Platform) e.sendAlert(r.Context(), rc, alerting.EventHardBlock, fmt.Sprintf("score %.0f >= %.0f", score, e.cfg.BlockThreshold)) + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) + setRetryAfter(w, e.cfg.BlockRetryAfter) challenge.RenderBlock(w, e.cfg.BlockStatusCode, rc.RequestID, e.cfg.Branding) return @@ -181,6 +186,8 @@ func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocogni e.recordCaptcha(r.Context(), eKey) e.addEvent(r.Context(), rc.ClientIP.String(), r.URL.Path, "captcha") + w.Header().Set(waf.HeaderAction, waf.ActionHeaderCaptcha) + switch { case e.powVerifier != nil: ch := e.powVerifier.GenerateChallenge(rc.ClientIP.String(), r.UserAgent()) @@ -208,27 +215,44 @@ func (e *Engine) Middleware() func(http.Handler) http.Handler { //nolint:gocogni } } -func (e *Engine) isSoftBlocked(key string) bool { +// isSoftBlocked reports whether key is currently soft-blocked, and returns the +// remaining duration (rounded up to whole seconds for Retry-After). +func (e *Engine) isSoftBlocked(key string) (bool, time.Duration) { data, exists, err := e.store.Get("esc:" + key) if err != nil || !exists { - return false + return false, 0 } var entry scoreEntry if err := json.Unmarshal(data, &entry); err != nil { - return false + return false, 0 } - if !entry.BlockedUntil.IsZero() && time.Now().Before(entry.BlockedUntil) { - return true - } - - // clear expired block if !entry.BlockedUntil.IsZero() { + remaining := time.Until(entry.BlockedUntil) + if remaining > 0 { + return true, remaining + } + + // clear expired block _ = e.store.Delete("esc:" + key) } - return false + return false, 0 +} + +// setRetryAfter writes Retry-After header in whole seconds when d > 0. +func setRetryAfter(w http.ResponseWriter, d time.Duration) { + if d <= 0 { + return + } + + secs := int(d.Round(time.Second) / time.Second) + if secs < 1 { + secs = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(secs)) } // verifyCaptchaCookie checks if the captcha cookie contains a valid token @@ -440,6 +464,8 @@ func (e *Engine) applyCaptchaFallback(w http.ResponseWriter, r *http.Request, rc e.metrics.Recorder.RecordDecision("captcha_fallback_block") e.addEvent(r.Context(), rc.ClientIP.String(), r.URL.Path, "captcha_fallback_block") e.sendAlert(r.Context(), rc, alerting.EventHardBlock, "captcha fallback block (platform: "+rc.Platform+")") + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) + setRetryAfter(w, e.cfg.BlockRetryAfter) challenge.RenderBlock(w, e.cfg.BlockStatusCode, rc.RequestID, e.cfg.Branding) case waf.ActionLog: rc.Decision = waf.ActionLog diff --git a/internal/waf/decide/decide_test.go b/internal/waf/decide/decide_test.go index 46a8311..13cbcab 100644 --- a/internal/waf/decide/decide_test.go +++ b/internal/waf/decide/decide_test.go @@ -49,7 +49,9 @@ func (s *DecideSuite) TestCaptchaAtThreshold() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithScore("1.1.1.1", 5)) - s.Equal(499, w.Code, "score at captcha threshold should return 499") + s.Equal(http.StatusForbidden, w.Code, "score at captcha threshold should return 403") + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction), "captcha must set X-WAF-Action: captcha") + s.Empty(w.Header().Get("Retry-After"), "captcha must NOT set Retry-After (semantics: solve, not wait)") s.Contains(w.Body.String(), "Captcha required") } @@ -61,9 +63,35 @@ func (s *DecideSuite) TestBlockAtThreshold() { handler.ServeHTTP(w, s.requestWithScore("1.1.1.1", 10)) s.Equal(http.StatusForbidden, w.Code, "score at block threshold should return 403") + s.Equal(waf.ActionHeaderBlock, w.Header().Get(waf.HeaderAction), "block must set X-WAF-Action: block") s.Contains(w.Body.String(), "Access Denied") } +func (s *DecideSuite) TestBlockRetryAfter() { + e := s.newEngine(5, 8) + e.cfg.BlockRetryAfter = 30 * time.Second + + handler := e.Middleware()(okHandler()) + w := httptest.NewRecorder() + handler.ServeHTTP(w, s.requestWithScore("1.1.1.1", 10)) + + s.Equal(http.StatusForbidden, w.Code) + s.Equal("30", w.Header().Get("Retry-After"), "block should expose configured Retry-After in seconds") +} + +func (s *DecideSuite) TestCaptchaNoRetryAfterEvenIfBlockSet() { + e := s.newEngine(5, 8) + e.cfg.BlockRetryAfter = 30 * time.Second + + handler := e.Middleware()(okHandler()) + w := httptest.NewRecorder() + handler.ServeHTTP(w, s.requestWithScore("1.1.1.1", 5)) + + s.Equal(http.StatusForbidden, w.Code) + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) + s.Empty(w.Header().Get("Retry-After"), "BlockRetryAfter must not bleed into captcha responses") +} + func (s *DecideSuite) TestWhitelistedBypass() { e := s.newEngine(5, 8) @@ -99,7 +127,7 @@ func (s *DecideSuite) TestCaptchaPassCacheBypass() { e := New(Config{ CaptchaThreshold: 5, BlockThreshold: 8, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, }, kvStore, cache, nil, nil, nil, attackOff(), embedlog.NewLogger(false, false), testMetrics()) @@ -130,13 +158,16 @@ func (s *DecideSuite) TestEscalationToSoftBlock() { for range 2 { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithScore("3.3.3.3", 6)) - s.Equal(499, w.Code) + s.Equal(http.StatusForbidden, w.Code) + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) } // next request should be soft-blocked w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithScore("3.3.3.3", 0)) // even score 0 s.Equal(http.StatusForbidden, w.Code, "should be soft-blocked after escalation") + s.Equal(waf.ActionHeaderBlock, w.Header().Get(waf.HeaderAction), "soft block must set X-WAF-Action: block") + s.NotEmpty(w.Header().Get("Retry-After"), "soft block should expose remaining TTL") } func (s *DecideSuite) TestZeroThresholdsPass() { @@ -160,7 +191,7 @@ func (s *DecideSuite) TestAttackScoreBoost() { 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 captcha", 2, true, 3, http.StatusForbidden, 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, ""}, @@ -180,7 +211,7 @@ func (s *DecideSuite) TestAttackScoreBoost() { CaptchaThreshold: 5, BlockThreshold: 8, AttackScoreBoost: tc.boost, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, @@ -227,7 +258,7 @@ func (s *DecideSuite) newEngine(captchaThreshold, blockThreshold float64) *Engin return New(Config{ CaptchaThreshold: captchaThreshold, BlockThreshold: blockThreshold, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, @@ -264,7 +295,7 @@ func BenchmarkDecidePass(b *testing.B) { e := New(Config{ CaptchaThreshold: 5, BlockThreshold: 8, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, @@ -295,7 +326,7 @@ func BenchmarkDecidePlatformCheck(b *testing.B) { e := New(Config{ CaptchaThreshold: 5, BlockThreshold: 8, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, @@ -431,7 +462,8 @@ func (s *DecideSuite) TestCaptchaRenderedForSupportedPlatform() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithPlatformScore("web", "")) - s.Equal(499, w.Code, "web platform should get captcha") + s.Equal(http.StatusForbidden, w.Code, "web platform should get captcha") + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) s.Contains(w.Body.String(), "Captcha required") } @@ -442,7 +474,8 @@ func (s *DecideSuite) TestCaptchaRenderedForVersionedPlatform() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithPlatformScore("ios", "3.0.0")) - s.Equal(499, w.Code, "iOS 3.0.0 should get captcha") + s.Equal(http.StatusForbidden, w.Code, "iOS 3.0.0 should get captcha") + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) } func (s *DecideSuite) TestEmptyPlatformGetsCaptcha() { @@ -452,7 +485,8 @@ func (s *DecideSuite) TestEmptyPlatformGetsCaptcha() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithPlatformScore("", "")) - s.Equal(499, w.Code, "empty platform (browser/SSR) should get captcha") + s.Equal(http.StatusForbidden, w.Code, "empty platform (browser/SSR) should get captcha") + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) s.Contains(w.Body.String(), "Captcha required") } @@ -470,7 +504,8 @@ func (s *DecideSuite) TestEscalationCompositeKey() { for range 2 { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithDiscriminator("1.1.1.1", 6, "bot:aaa")) - s.Equal(499, w.Code) + s.Equal(http.StatusForbidden, w.Code) + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) } // Bot should be soft-blocked @@ -495,7 +530,8 @@ func (s *DecideSuite) TestSoftBlockPerDiscriminator() { // Trigger soft block for discriminator A w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithDiscriminator("2.2.2.2", 6, "iOS:aaa")) - s.Equal(499, w.Code) + s.Equal(http.StatusForbidden, w.Code) + s.Equal(waf.ActionHeaderCaptcha, w.Header().Get(waf.HeaderAction)) // A is now soft-blocked w = httptest.NewRecorder() @@ -523,7 +559,7 @@ func (s *DecideSuite) newPlatformEngine() *Engine { return New(Config{ CaptchaThreshold: 5, BlockThreshold: 8, - CaptchaStatusCode: 499, + CaptchaStatusCode: http.StatusForbidden, BlockStatusCode: http.StatusForbidden, CaptchaToBlock: 3, CaptchaToBlockWindow: 10 * time.Minute, diff --git a/internal/waf/ip/service.go b/internal/waf/ip/service.go index 7390cb5..db55363 100644 --- a/internal/waf/ip/service.go +++ b/internal/waf/ip/service.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/netip" + "strconv" "strings" "sync" "time" @@ -296,6 +297,20 @@ func (e *BlockEntry) isExpired(now time.Time) bool { return !e.ExpiresAt.IsZero() && now.After(e.ExpiresAt) } +// remaining returns time until ExpiresAt; 0 for permanent entries. +func (e *BlockEntry) remaining(now time.Time) time.Duration { + if e.ExpiresAt.IsZero() { + return 0 + } + + d := e.ExpiresAt.Sub(now) + if d < 0 { + return 0 + } + + return d +} + // Legacy methods for backward compatibility. // AddBlacklist adds an IP to the runtime blacklist (no metadata). @@ -360,9 +375,12 @@ func (s *Service) Middleware() func(http.Handler) http.Handler { return } - if s.isBlacklisted(rc.ClientIP) { + if blocked, remaining := s.isBlacklisted(rc.ClientIP); blocked { + rc.Decision = waf.ActionBlock s.metrics.BlockedTotal.WithLabelValues("blacklist").Inc() s.addEvent(r, rc, "ip_blocked", "blacklist") + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) + setRetryAfter(w, remaining) http.Error(w, "Forbidden", http.StatusForbidden) return } @@ -371,8 +389,10 @@ func (s *Service) Middleware() func(http.Handler) http.Handler { s.geo.lookup(rc.ClientIP, info) if s.isCountryBlocked(info.Country) { + rc.Decision = waf.ActionBlock s.metrics.BlockedTotal.WithLabelValues("country").Inc() s.addEvent(r, rc, "ip_blocked", "country:"+info.Country) + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) http.Error(w, "Forbidden", http.StatusForbidden) return @@ -437,11 +457,13 @@ func (s *Service) isWhitelisted(ip netip.Addr) bool { return false } -func (s *Service) isBlacklisted(ip netip.Addr) bool { - // static config +// isBlacklisted reports whether ip is blacklisted and the remaining TTL for +// runtime entries with ExpiresAt set (0 = permanent or static config). +func (s *Service) isBlacklisted(ip netip.Addr) (bool, time.Duration) { + // static config — permanent for _, p := range s.cfg.Blacklist { if p.Contains(ip) { - return true + return true, 0 } } @@ -453,17 +475,31 @@ func (s *Service) isBlacklisted(ip netip.Addr) bool { // runtime IPs if e, ok := s.blockedIPs[ip]; ok && !e.isExpired(now) { - return true + return true, e.remaining(now) } // runtime CIDRs for prefix, e := range s.blockedCIDRs { if prefix.Contains(ip) && !e.isExpired(now) { - return true + return true, e.remaining(now) } } - return false + return false, 0 +} + +// setRetryAfter writes Retry-After header in whole seconds when d > 0. +func setRetryAfter(w http.ResponseWriter, d time.Duration) { + if d <= 0 { + return + } + + secs := int(d.Round(time.Second) / time.Second) + if secs < 1 { + secs = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(secs)) } func (s *Service) addEvent(r *http.Request, rc *waf.RequestContext, eventType, detail string) { @@ -530,8 +566,10 @@ func (s *Service) checkReputation(w http.ResponseWriter, r *http.Request, rc *wa switch result.Action { case FeedActionBlock: + rc.Decision = waf.ActionBlock s.metrics.BlockedTotal.WithLabelValues("reputation").Inc() s.addEvent(r, rc, "ip_blocked", "reputation:"+strings.Join(result.Feeds, ",")) + w.Header().Set(waf.HeaderAction, waf.ActionHeaderBlock) http.Error(w, "Forbidden", http.StatusForbidden) return true diff --git a/internal/waf/ip/service_test.go b/internal/waf/ip/service_test.go index 2e445bc..f78f383 100644 --- a/internal/waf/ip/service_test.go +++ b/internal/waf/ip/service_test.go @@ -44,6 +44,8 @@ func (s *ServiceSuite) TestBlacklistBlock() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithIP("1.2.3.4")) s.Equal(http.StatusForbidden, w.Code, "blacklisted IP should be blocked") + s.Equal(waf.ActionHeaderBlock, w.Header().Get(waf.HeaderAction), "static blacklist must set X-WAF-Action: block") + s.Empty(w.Header().Get("Retry-After"), "static (permanent) blacklist must not set Retry-After") } func (s *ServiceSuite) TestNonListedPass() { @@ -150,7 +152,7 @@ func (s *ServiceSuite) TestAddBlockCountry() { func (s *ServiceSuite) TestBlockWithDuration() { svc := s.newService(Config{}) - err := svc.AddBlock("ip", "6.6.6.6", "temp", 100*time.Millisecond) + err := svc.AddBlock("ip", "6.6.6.6", "temp", 30*time.Second) s.Require().NoError(err) // immediately → blocked @@ -158,12 +160,15 @@ func (s *ServiceSuite) TestBlockWithDuration() { w1 := httptest.NewRecorder() handler.ServeHTTP(w1, s.requestWithIP("6.6.6.6")) s.Equal(http.StatusForbidden, w1.Code, "should be blocked before expiry") + s.Equal(waf.ActionHeaderBlock, w1.Header().Get(waf.HeaderAction)) + s.NotEmpty(w1.Header().Get("Retry-After"), "TTL'd block should expose remaining time as Retry-After") - // wait for expiry + // short TTL still works for expiry path + s.Require().NoError(svc.AddBlock("ip", "7.7.7.7", "temp", 80*time.Millisecond)) time.Sleep(150 * time.Millisecond) w2 := httptest.NewRecorder() - handler.ServeHTTP(w2, s.requestWithIP("6.6.6.6")) + handler.ServeHTTP(w2, s.requestWithIP("7.7.7.7")) s.Equal(http.StatusOK, w2.Code, "should pass after TTL expires") } diff --git a/internal/waf/limit/limiter.go b/internal/waf/limit/limiter.go index c2af462..729541e 100644 --- a/internal/waf/limit/limiter.go +++ b/internal/waf/limit/limiter.go @@ -26,8 +26,9 @@ type Config struct { PerIP Rate Action string // "block" | "throttle" MaxCounters int - Rules []Rule // RPC method matching - URLRules []URLRule // HTTP path/method/host matching + RetryAfter time.Duration // sent as Retry-After header on 429 + Rules []Rule // RPC method matching + URLRules []URLRule // HTTP path/method/host matching } // Rule defines a per-method rate limit (JSON-RPC matching). @@ -119,6 +120,7 @@ func (l *Limiter) Middleware() func(http.Handler) http.Handler { } if !l.allowIP(rc.ClientIP.String()) { + rc.Decision = waf.ActionThrottle l.metrics.ExceededTotal.WithLabelValues("per_ip", l.cfg.Action).Inc() l.Print(r.Context(), "rate_limit", "clientIp", rc.ClientIP.String(), @@ -210,6 +212,7 @@ func (l *Limiter) enforce(w http.ResponseWriter, r *http.Request, rc *waf.Reques return true } + rc.Decision = waf.ActionThrottle l.metrics.ExceededTotal.WithLabelValues(name, action).Inc() l.addEvent(rc.ClientIP.String(), r.URL.Path, name, rc.Platform) l.Print(r.Context(), "rate_limit", @@ -253,6 +256,11 @@ func (l *Limiter) addEvent(clientIP, path, rule, platform string) { } func (l *Limiter) reject(w http.ResponseWriter) { - w.Header().Set("Retry-After", "60") + w.Header().Set(waf.HeaderAction, waf.ActionHeaderThrottle) + + if l.cfg.RetryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int(l.cfg.RetryAfter.Seconds()))) + } + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) } diff --git a/internal/waf/limit/limiter_test.go b/internal/waf/limit/limiter_test.go index 2814ad0..67c509d 100644 --- a/internal/waf/limit/limiter_test.go +++ b/internal/waf/limit/limiter_test.go @@ -59,6 +59,7 @@ func (s *LimiterSuite) TestPerIPLimit() { PerIP: Rate{Count: 3, Duration: time.Minute}, Action: "block", MaxCounters: 1000, + RetryAfter: 45 * time.Second, }) handler := l.Middleware()(okHandler()) @@ -72,7 +73,28 @@ func (s *LimiterSuite) TestPerIPLimit() { w := httptest.NewRecorder() handler.ServeHTTP(w, s.requestWithIP("1.2.3.4")) s.Equal(http.StatusTooManyRequests, w.Code, "4th request should be limited") - s.NotEmpty(w.Header().Get("Retry-After")) + s.Equal(waf.ActionHeaderThrottle, w.Header().Get(waf.HeaderAction), "throttle must set X-WAF-Action: throttle") + s.Equal("45", w.Header().Get("Retry-After"), "Retry-After must come from cfg.RetryAfter, not hardcoded") +} + +func (s *LimiterSuite) TestRetryAfterOmittedWhenZero() { + l := s.newLimiter(Config{ + PerIP: Rate{Count: 1, Duration: time.Minute}, + Action: "block", + MaxCounters: 1000, + }) + + handler := l.Middleware()(okHandler()) + + // burn the only token, then trip the limit + w1 := httptest.NewRecorder() + handler.ServeHTTP(w1, s.requestWithIP("9.9.9.9")) + s.Equal(http.StatusOK, w1.Code) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, s.requestWithIP("9.9.9.9")) + s.Equal(http.StatusTooManyRequests, w.Code) + s.Empty(w.Header().Get("Retry-After"), "Retry-After must be omitted when RetryAfter=0") } func (s *LimiterSuite) TestDifferentIPsIndependent() { diff --git a/internal/waf/model.go b/internal/waf/model.go index 87fd910..5f6d657 100644 --- a/internal/waf/model.go +++ b/internal/waf/model.go @@ -9,6 +9,18 @@ type AttackState interface { IsEnabled() bool } +// HeaderAction is the response header that programmatic clients read to tell +// challenge / block / throttle apart when the HTTP status alone is ambiguous +// (after captcha migrated from 499 to 403, both captcha and block return 403). +const HeaderAction = "X-WAF-Action" + +// X-WAF-Action header values. +const ( + ActionHeaderCaptcha = "captcha" + ActionHeaderBlock = "block" + ActionHeaderThrottle = "throttle" +) + // Action represents a WAF decision action. type Action int @@ -16,7 +28,7 @@ const ( ActionPass Action = iota ActionLog // detection mode: log but pass ActionThrottle // 429 - ActionCaptcha // 499 — captcha required + ActionCaptcha // 403 — captcha required (X-WAF-Action: captcha) ActionSoftBlock // 403, temporary (with TTL) ActionHardBlock // 403, permanent (until manual unblock) ActionBlock // 403, generic diff --git a/internal/waf/proxy/proxy.go b/internal/waf/proxy/proxy.go index 200ba02..48870b0 100644 --- a/internal/waf/proxy/proxy.go +++ b/internal/waf/proxy/proxy.go @@ -199,7 +199,9 @@ func (p *Proxy) HandlerWithLatency(lr LatencyRecorder) http.Handler { } if p.errRecorder != nil { - p.errRecorder.RecordProxyError("", ErrorReasonNoBackends) + // "-" placeholder: no specific backend (pool empty); avoids + // the empty-string label rendering as "no data" in Grafana. + p.errRecorder.RecordProxyError("-", ErrorReasonNoBackends) } http.Error(w, "no backends available", http.StatusServiceUnavailable)