Repository navigation
Expand file tree
/
Copy pathcache.go
More file actions
223 lines (203 loc) · 5.86 KB
/
Copy pathcache.go
File metadata and controls
223 lines (203 loc) · 5.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
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
}