Skip to content

Commit 2b8e464

Browse files
committed
feat(webhook): add rate limiting to the webhook endpoint
Signed-off-by: Christopher Coco <[email protected]>
1 parent 18767ed commit 2b8e464

File tree

6 files changed

+472
-10
lines changed

6 files changed

+472
-10
lines changed

cmd/run.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ func newRunCommand() *cobra.Command {
216216
log.Infof("Starting webhook server on port %d", webhookCfg.Port)
217217
webhookServer = webhook.NewWebhookServer(webhookCfg.Port, handler, cfg.KubeClient, argoClient)
218218

219+
if webhookCfg.RateLimitEnabled {
220+
limiter := webhook.NewRateLimiter(webhookCfg.RateLimitNumAllowedRequests, webhookCfg.RateLimitWindow, webhookCfg.RateLimitCleanUpInterval)
221+
webhookServer.RateLimiter = limiter
222+
}
223+
219224
// Set updater config
220225
webhookServer.UpdaterConfig = &argocd.UpdateConfiguration{
221226
NewRegFN: registry.NewClient,
@@ -271,6 +276,9 @@ func newRunCommand() *cobra.Command {
271276
if err := webhookServer.Stop(); err != nil {
272277
log.Errorf("Error stopping webhook server: %v", err)
273278
}
279+
if webhookCfg.RateLimitEnabled {
280+
webhookServer.RateLimiter.StopCleanUp()
281+
}
274282
}
275283
return nil
276284
case err := <-whErrCh:
@@ -337,6 +345,10 @@ func newRunCommand() *cobra.Command {
337345
runCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks")
338346
runCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks")
339347
runCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks")
348+
runCmd.Flags().BoolVar(&webhookCfg.RateLimitEnabled, "enable-webhook-ratelimit", false, "Enable rate limiting for the webhook endpoint")
349+
runCmd.Flags().IntVar(&webhookCfg.RateLimitNumAllowedRequests, "webhook-ratelimit-num-allowed", 100, "The number of allowed requests in a window for webhook rate limiting")
350+
runCmd.Flags().DurationVar(&webhookCfg.RateLimitWindow, "webhook-ratelimit-window", 2*time.Minute, "The duration for the window for the webhook rate limiting")
351+
runCmd.Flags().DurationVar(&webhookCfg.RateLimitCleanUpInterval, "webhook-ratelimit-cleanup-interval", 1*time.Hour, "How often the rate limiter cleans up stale clients")
340352

341353
return runCmd
342354
}

cmd/webhook.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"syscall"
1212
"text/template"
13+
"time"
1314

1415
"github.com/argoproj-labs/argocd-image-updater/pkg/argocd"
1516
"github.com/argoproj-labs/argocd-image-updater/pkg/common"
@@ -26,11 +27,15 @@ import (
2627

2728
// WebhookConfig holds the options for the webhook server
2829
type WebhookConfig struct {
29-
Port int
30-
DockerSecret string
31-
GHCRSecret string
32-
QuaySecret string
33-
HarborSecret string
30+
Port int
31+
DockerSecret string
32+
GHCRSecret string
33+
QuaySecret string
34+
HarborSecret string
35+
RateLimitEnabled bool
36+
RateLimitNumAllowedRequests int
37+
RateLimitWindow time.Duration
38+
RateLimitCleanUpInterval time.Duration
3439
}
3540

3641
// NewWebhookCommand creates a new webhook command
@@ -190,6 +195,10 @@ Supported registries:
190195
webhookCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks")
191196
webhookCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks")
192197
webhookCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks")
198+
webhookCmd.Flags().BoolVar(&webhookCfg.RateLimitEnabled, "enable-webhook-ratelimit", false, "Enable rate limiting for the webhook endpoint")
199+
webhookCmd.Flags().IntVar(&webhookCfg.RateLimitNumAllowedRequests, "webhook-ratelimit-num-allowed", 100, "The number of allowed requests in a window for webhook rate limiting")
200+
webhookCmd.Flags().DurationVar(&webhookCfg.RateLimitWindow, "webhook-ratelimit-window", 2*time.Minute, "The duration for the window for the webhook rate limiting")
201+
webhookCmd.Flags().DurationVar(&webhookCfg.RateLimitCleanUpInterval, "webhook-ratelimit-cleanup-interval", 1*time.Hour, "How often the rate limiter cleans up stale clients")
193202

194203
return webhookCmd
195204
}
@@ -238,6 +247,11 @@ func runWebhook(cfg *ImageUpdaterConfig, webhookCfg *WebhookConfig) error {
238247
// Create webhook server
239248
server := webhook.NewWebhookServer(webhookCfg.Port, handler, cfg.KubeClient, cfg.ArgoClient)
240249

250+
if webhookCfg.RateLimitEnabled {
251+
limiter := webhook.NewRateLimiter(webhookCfg.RateLimitNumAllowedRequests, webhookCfg.RateLimitWindow, webhookCfg.RateLimitCleanUpInterval)
252+
server.RateLimiter = limiter
253+
}
254+
241255
// Set updater config
242256
server.UpdaterConfig = &argocd.UpdateConfiguration{
243257
NewRegFN: registry.NewClient,
@@ -273,5 +287,10 @@ func runWebhook(cfg *ImageUpdaterConfig, webhookCfg *WebhookConfig) error {
273287
if err := server.Stop(); err != nil {
274288
log.Errorf("Error stopping webhook server: %v", err)
275289
}
290+
291+
if webhookCfg.RateLimitEnabled {
292+
server.RateLimiter.StopCleanUp()
293+
}
294+
276295
return nil
277296
}

pkg/webhook/ratelimit.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package webhook
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// RateLimiter implements a sliding window rate limiting algorithm
9+
type RateLimiter struct {
10+
mu sync.Mutex
11+
clients map[string][]int64
12+
lastSeen map[string]time.Time
13+
window time.Duration
14+
allowed int
15+
done chan bool
16+
}
17+
18+
func NewRateLimiter(numRequets int, window time.Duration, cleanUpInterval time.Duration) *RateLimiter {
19+
limiter := RateLimiter{
20+
clients: make(map[string][]int64),
21+
lastSeen: make(map[string]time.Time),
22+
window: window,
23+
allowed: numRequets,
24+
done: make(chan bool),
25+
}
26+
go limiter.StartCleanUp(cleanUpInterval)
27+
return &limiter
28+
}
29+
30+
// Checks to see if a client has gone over the limit
31+
func (rl *RateLimiter) Allow(clientIP string) bool {
32+
now := time.Now()
33+
rl.mu.Lock()
34+
defer rl.mu.Unlock()
35+
36+
if _, ok := rl.clients[clientIP]; !ok {
37+
rl.clients[clientIP] = []int64{}
38+
}
39+
40+
allow := false
41+
windowStart := now.Unix() - int64(rl.window.Seconds())
42+
filtered := []int64{}
43+
for _, ts := range rl.clients[clientIP] {
44+
if ts > windowStart {
45+
filtered = append(filtered, ts)
46+
}
47+
}
48+
rl.clients[clientIP] = filtered
49+
50+
if len(filtered) < rl.allowed {
51+
rl.clients[clientIP] = append(filtered, now.Unix())
52+
rl.lastSeen[clientIP] = now
53+
allow = true
54+
}
55+
56+
return allow
57+
}
58+
59+
// Cleans up the clients map at an interval to prevent stale clients from taking up memory
60+
func (rl *RateLimiter) StartCleanUp(interval time.Duration) {
61+
ticker := time.NewTicker(interval)
62+
defer ticker.Stop()
63+
64+
for {
65+
select {
66+
case <-rl.done:
67+
return
68+
case <-ticker.C:
69+
rl.CleanUp()
70+
}
71+
}
72+
}
73+
74+
// Cleans up any clients that have not made a request in an amount of time over the window
75+
func (rl *RateLimiter) CleanUp() {
76+
rl.mu.Lock()
77+
defer rl.mu.Unlock()
78+
79+
for k, v := range rl.lastSeen {
80+
now := time.Now()
81+
if v.Add(rl.window).Before(now) {
82+
delete(rl.clients, k)
83+
delete(rl.lastSeen, k)
84+
}
85+
}
86+
}
87+
88+
// Stop the clean up goroutine
89+
func (rl *RateLimiter) StopCleanUp() {
90+
rl.done <- true
91+
}

0 commit comments

Comments
 (0)