Files
traefik-botfilter/cache.go
T

224 lines
5.9 KiB
Go

package traefik_botfilter
import (
"sync"
"time"
)
type scoreEvent struct {
at time.Time
points int
}
type clientState struct {
banUntil time.Time
lastSeen time.Time
requests int
sawHome bool
sawStatic bool
sawFavicon bool
events []scoreEvent
}
type requestObservation struct {
at time.Time
paths []string
points int
forceBan bool
}
type cacheResult struct {
banned bool
banUntil time.Time
score int
}
// clientCache deliberately has no background goroutine. A middleware
// instance can be discarded on a Traefik reload without needing goroutine
// cleanup, and opportunistic cleanup keeps its memory use bounded.
type clientCache struct {
mu sync.Mutex
entries map[string]*clientState
maxEntries int
maxEvents int
scoreWindow time.Duration
banDuration time.Duration
threshold int
randomPaths []string
randomScore int
operations uint64
}
func newClientCache(cfg *compiledConfig) *clientCache {
return &clientCache{
entries: make(map[string]*clientState),
maxEntries: cfg.MaxTrackedIPs,
maxEvents: cfg.MaxScoreEventsPerIP,
scoreWindow: cfg.scoreWindow,
banDuration: cfg.banDuration,
threshold: cfg.ScoreThreshold,
randomPaths: append([]string(nil), cfg.randomPaths...),
randomScore: cfg.RandomArticleScore,
}
}
func (c *clientCache) isBanned(ip string, now time.Time) cacheResult {
if ip == "" {
return cacheResult{}
}
c.mu.Lock()
defer c.mu.Unlock()
state := c.entries[ip]
if state == nil || !state.banUntil.After(now) {
return cacheResult{}
}
return cacheResult{banned: true, banUntil: state.banUntil}
}
func (c *clientCache) observeRequest(ip string, observation requestObservation) cacheResult {
if ip == "" {
// An invalid peer address must never share a cache entry with another
// malformed request. The request can still be rejected by its headers.
return cacheResult{banned: observation.forceBan}
}
c.mu.Lock()
defer c.mu.Unlock()
c.operations++
c.cleanupLocked(observation.at)
state := c.entries[ip]
if state != nil && state.banUntil.After(observation.at) {
return cacheResult{banned: true, banUntil: state.banUntil}
}
if state == nil {
c.ensureCapacityLocked(observation.at)
state = &clientState{}
c.entries[ip] = state
}
c.pruneEventsLocked(state, observation.at)
points := observation.points + c.behaviorScoreLocked(state, observation.paths)
state.requests++
state.lastSeen = observation.at
if points > 0 {
state.events = append(state.events, scoreEvent{at: observation.at, points: points})
if len(state.events) > c.maxEvents {
state.events = append([]scoreEvent(nil), state.events[len(state.events)-c.maxEvents:]...)
}
}
score := sumScore(state.events)
if observation.forceBan || score >= c.threshold {
state.banUntil = observation.at.Add(c.banDuration)
return cacheResult{banned: true, banUntil: state.banUntil, score: score}
}
return cacheResult{score: score}
}
func (c *clientCache) observeResponse(ip string, status int, points int, now time.Time) cacheResult {
if ip == "" || status != 404 || points <= 0 {
return cacheResult{}
}
c.mu.Lock()
defer c.mu.Unlock()
state := c.entries[ip]
if state == nil || state.banUntil.After(now) {
return cacheResult{}
}
c.pruneEventsLocked(state, now)
state.lastSeen = now
state.events = append(state.events, scoreEvent{at: now, points: points})
if len(state.events) > c.maxEvents {
state.events = append([]scoreEvent(nil), state.events[len(state.events)-c.maxEvents:]...)
}
score := sumScore(state.events)
if score >= c.threshold {
state.banUntil = now.Add(c.banDuration)
return cacheResult{banned: true, banUntil: state.banUntil, score: score}
}
return cacheResult{score: score}
}
func (c *clientCache) behaviorScoreLocked(state *clientState, paths []string) int {
// The heuristic is intentionally weak: visiting a content page first is
// normal for a shared link, so it adds only the configured 15 points.
// It becomes useful in combination with malformed headers or 404 scans.
firstRequest := state.requests == 0
isContent := false
for _, requestPath := range paths {
if requestPath == "/" || requestPath == "/index.html" {
state.sawHome = true
}
if requestPath == "/favicon.ico" {
state.sawFavicon = true
}
if hasStaticAssetExtension(requestPath) {
state.sawStatic = true
}
if pathMatchesAny(requestPath, c.randomPaths) {
isContent = true
}
}
if firstRequest && isContent && !state.sawHome && !state.sawStatic && !state.sawFavicon {
return c.randomScore
}
return 0
}
func (c *clientCache) pruneEventsLocked(state *clientState, now time.Time) {
cutoff := now.Add(-c.scoreWindow)
first := 0
for first < len(state.events) && !state.events[first].at.After(cutoff) {
first++
}
if first > 0 {
state.events = append([]scoreEvent(nil), state.events[first:]...)
}
}
func (c *clientCache) cleanupLocked(now time.Time) {
// Full cleanup every 256 requests amortises the map scan while retaining
// recently scored clients for the entire score window.
if c.operations%256 != 0 {
return
}
cutoff := now.Add(-c.scoreWindow)
for ip, state := range c.entries {
if !state.banUntil.After(now) && !state.lastSeen.After(cutoff) {
delete(c.entries, ip)
}
}
}
func (c *clientCache) ensureCapacityLocked(now time.Time) {
if len(c.entries) < c.maxEntries {
return
}
// Evict the oldest non-banned entry first. If all entries are banned, the
// oldest ban is evicted; the cap remains a hard upper bound either way.
var oldestIP string
var oldestTime time.Time
for ip, state := range c.entries {
candidate := state.lastSeen
if state.banUntil.After(now) {
candidate = state.banUntil
}
if oldestIP == "" || candidate.Before(oldestTime) {
oldestIP, oldestTime = ip, candidate
}
}
if oldestIP != "" {
delete(c.entries, oldestIP)
}
}
func sumScore(events []scoreEvent) int {
total := 0
for _, event := range events {
total += event.points
}
return total
}