Initial version

This commit is contained in:
2026-08-03 10:53:59 +08:00
commit 0c79eb3806
11 changed files with 1510 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
package 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
}