package traefik_botfilter import ( "fmt" "net/netip" "net/textproto" "strings" "time" ) // Config is the plugin configuration exposed by Traefik's dynamic file // provider. All durations are expressed as integers because Traefik plugin // configuration is deliberately kept YAML-friendly. type Config struct { StatusCode int `json:"statusCode,omitempty" yaml:"statusCode,omitempty" toml:"statusCode,omitempty"` RequireUserAgent bool `json:"requireUserAgent,omitempty" yaml:"requireUserAgent,omitempty" toml:"requireUserAgent,omitempty"` RequireAccept bool `json:"requireAccept,omitempty" yaml:"requireAccept,omitempty" toml:"requireAccept,omitempty"` RequireHost bool `json:"requireHost,omitempty" yaml:"requireHost,omitempty" toml:"requireHost,omitempty"` BrowserValidation bool `json:"browserValidation,omitempty" yaml:"browserValidation,omitempty" toml:"browserValidation,omitempty"` WhitelistCIDRs []string `json:"whitelistCIDRs,omitempty" yaml:"whitelistCIDRs,omitempty" toml:"whitelistCIDRs,omitempty"` TemporaryBanMinutes int `json:"temporaryBanMinutes,omitempty" yaml:"temporaryBanMinutes,omitempty" toml:"temporaryBanMinutes,omitempty"` BlockedUserAgents []string `json:"blockedUserAgents,omitempty" yaml:"blockedUserAgents,omitempty" toml:"blockedUserAgents,omitempty"` BlockedPaths []string `json:"blockedPaths,omitempty" yaml:"blockedPaths,omitempty" toml:"blockedPaths,omitempty"` BlockedExtensions []string `json:"blockedExtensions,omitempty" yaml:"blockedExtensions,omitempty" toml:"blockedExtensions,omitempty"` ScoreThreshold int `json:"scoreThreshold,omitempty" yaml:"scoreThreshold,omitempty" toml:"scoreThreshold,omitempty"` ScoreWindowMinutes int `json:"scoreWindowMinutes,omitempty" yaml:"scoreWindowMinutes,omitempty" toml:"scoreWindowMinutes,omitempty"` MaxTrackedIPs int `json:"maxTrackedIPs,omitempty" yaml:"maxTrackedIPs,omitempty" toml:"maxTrackedIPs,omitempty"` MaxScoreEventsPerIP int `json:"maxScoreEventsPerIP,omitempty" yaml:"maxScoreEventsPerIP,omitempty" toml:"maxScoreEventsPerIP,omitempty"` EmptyUserAgentScore int `json:"emptyUserAgentScore,omitempty" yaml:"emptyUserAgentScore,omitempty" toml:"emptyUserAgentScore,omitempty"` MissingAcceptScore int `json:"missingAcceptScore,omitempty" yaml:"missingAcceptScore,omitempty" toml:"missingAcceptScore,omitempty"` BlockedUserAgentScore int `json:"blockedUserAgentScore,omitempty" yaml:"blockedUserAgentScore,omitempty" toml:"blockedUserAgentScore,omitempty"` BadPathScore int `json:"badPathScore,omitempty" yaml:"badPathScore,omitempty" toml:"badPathScore,omitempty"` RandomArticleScore int `json:"randomArticleScore,omitempty" yaml:"randomArticleScore,omitempty" toml:"randomArticleScore,omitempty"` NotFoundScore int `json:"notFoundScore,omitempty" yaml:"notFoundScore,omitempty" toml:"notFoundScore,omitempty"` FakeBrowserScore int `json:"fakeBrowserScore,omitempty" yaml:"fakeBrowserScore,omitempty" toml:"fakeBrowserScore,omitempty"` RandomArticlePatterns []string `json:"randomArticlePatterns,omitempty" yaml:"randomArticlePatterns,omitempty" toml:"randomArticlePatterns,omitempty"` ClientIPHeader string `json:"clientIPHeader,omitempty" yaml:"clientIPHeader,omitempty" toml:"clientIPHeader,omitempty"` TrustedProxyCIDRs []string `json:"trustedProxyCIDRs,omitempty" yaml:"trustedProxyCIDRs,omitempty" toml:"trustedProxyCIDRs,omitempty"` LogBlockedRequests bool `json:"logBlockedRequests,omitempty" yaml:"logBlockedRequests,omitempty" toml:"logBlockedRequests,omitempty"` } // CreateConfig creates the default configuration. The defaults protect common // public HTTP services without requiring a third-party dependency. func CreateConfig() *Config { return &Config{ StatusCode: 403, TemporaryBanMinutes: 15, ScoreThreshold: 100, ScoreWindowMinutes: 15, MaxTrackedIPs: 50000, MaxScoreEventsPerIP: 16, EmptyUserAgentScore: 40, MissingAcceptScore: 20, BlockedUserAgentScore: 80, BadPathScore: 50, RandomArticleScore: 15, NotFoundScore: 40, FakeBrowserScore: 40, RandomArticlePatterns: []string{"/content/"}, } } type compiledConfig struct { Config banDuration time.Duration scoreWindow time.Duration whitelist []netip.Prefix trustedProxies []netip.Prefix blockedAgents []string blockedPaths []string blockedExts []string randomPaths []string clientIPHeader string } func compileConfig(input *Config) (*compiledConfig, error) { if input == nil { return nil, fmt.Errorf("botfilter: configuration is nil") } // Copy scalar fields and slices so a later configuration reload cannot // mutate an already-running middleware instance. cfg := *input cfg.WhitelistCIDRs = append([]string(nil), input.WhitelistCIDRs...) cfg.TrustedProxyCIDRs = append([]string(nil), input.TrustedProxyCIDRs...) cfg.BlockedUserAgents = append([]string(nil), input.BlockedUserAgents...) cfg.BlockedPaths = append([]string(nil), input.BlockedPaths...) cfg.BlockedExtensions = append([]string(nil), input.BlockedExtensions...) cfg.RandomArticlePatterns = append([]string(nil), input.RandomArticlePatterns...) defaults := CreateConfig() applyDefaults(&cfg, defaults) if cfg.StatusCode < 400 || cfg.StatusCode > 599 { return nil, fmt.Errorf("botfilter: statusCode must be between 400 and 599") } if cfg.TemporaryBanMinutes <= 0 { return nil, fmt.Errorf("botfilter: temporaryBanMinutes must be greater than zero") } if cfg.ScoreThreshold <= 0 || cfg.ScoreWindowMinutes <= 0 { return nil, fmt.Errorf("botfilter: scoreThreshold and scoreWindowMinutes must be greater than zero") } if cfg.MaxTrackedIPs <= 0 || cfg.MaxScoreEventsPerIP <= 0 { return nil, fmt.Errorf("botfilter: maxTrackedIPs and maxScoreEventsPerIP must be greater than zero") } whitelist, err := parseCIDRs(cfg.WhitelistCIDRs, "whitelistCIDRs") if err != nil { return nil, err } trusted, err := parseCIDRs(cfg.TrustedProxyCIDRs, "trustedProxyCIDRs") if err != nil { return nil, err } return &compiledConfig{ Config: cfg, banDuration: time.Duration(cfg.TemporaryBanMinutes) * time.Minute, scoreWindow: time.Duration(cfg.ScoreWindowMinutes) * time.Minute, whitelist: whitelist, trustedProxies: trusted, blockedAgents: normaliseTokens(cfg.BlockedUserAgents), blockedPaths: normalisePaths(cfg.BlockedPaths), blockedExts: normaliseExtensions(cfg.BlockedExtensions), randomPaths: normalisePaths(cfg.RandomArticlePatterns), clientIPHeader: textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(cfg.ClientIPHeader)), }, nil } func applyDefaults(cfg, defaults *Config) { if cfg.StatusCode == 0 { cfg.StatusCode = defaults.StatusCode } if cfg.TemporaryBanMinutes == 0 { cfg.TemporaryBanMinutes = defaults.TemporaryBanMinutes } if cfg.ScoreThreshold == 0 { cfg.ScoreThreshold = defaults.ScoreThreshold } if cfg.ScoreWindowMinutes == 0 { cfg.ScoreWindowMinutes = defaults.ScoreWindowMinutes } if cfg.MaxTrackedIPs == 0 { cfg.MaxTrackedIPs = defaults.MaxTrackedIPs } if cfg.MaxScoreEventsPerIP == 0 { cfg.MaxScoreEventsPerIP = defaults.MaxScoreEventsPerIP } // Score fields deliberately do not receive fallback values here. Traefik // starts from CreateConfig(), so omitted values retain their defaults, while // an explicit YAML zero remains a useful way to disable one signal. } func parseCIDRs(values []string, field string) ([]netip.Prefix, error) { result := make([]netip.Prefix, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } prefix, err := netip.ParsePrefix(value) if err != nil { return nil, fmt.Errorf("botfilter: invalid %s entry %q: %w", field, value, err) } result = append(result, prefix.Masked()) } return result, nil }