From 1962abdc16866ea95666cd2bb3e984ba07456eed Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 19 Aug 2025 01:58:13 +0530 Subject: [PATCH] feat: implement rate limiting for public widget endpoints with Redis support --- cmd/handlers.go | 12 ++--- cmd/init.go | 10 ++++ cmd/main.go | 4 ++ cmd/widget_middleware.go | 11 ++++ config.sample.toml | 5 ++ internal/ratelimit/ratelimit.go | 96 +++++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 internal/ratelimit/ratelimit.go diff --git a/cmd/handlers.go b/cmd/handlers.go index 6f88dc01..22071e18 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -227,12 +227,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Widget APIs. g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings) g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) - g.POST("/api/v1/widget/chat/conversations/init", widgetAuth(handleChatInit)) - g.POST("/api/v1/widget/chat/conversations", widgetAuth(handleGetConversations)) - g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetAuth(handleChatUpdateLastSeen)) - g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetAuth(handleChatGetConversation)) - g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetAuth(handleChatSendMessage)) - g.POST("/api/v1/widget/media/upload", widgetAuth(handleWidgetMediaUpload)) + g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit))) + g.POST("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations))) + g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen))) + g.POST("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation))) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage))) + g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload))) // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) diff --git a/cmd/init.go b/cmd/init.go index b88aed9e..108c7fd6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -36,6 +36,7 @@ import ( notifier "github.com/abhinavxd/libredesk/internal/notification" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" + "github.com/abhinavxd/libredesk/internal/ratelimit" "github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/search" @@ -927,3 +928,12 @@ func getLogLevel(lvl string) logf.Level { return logf.InfoLevel } } + +// initRateLimit initializes the rate limiter. +func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter { + var config ratelimit.Config + if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil { + log.Fatalf("error unmarshalling rate limit config: %v", err) + } + return ratelimit.New(redisClient, config) +} diff --git a/cmd/main.go b/cmd/main.go index bc2f4149..87081f96 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -35,6 +35,7 @@ import ( "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/oidc" + "github.com/abhinavxd/libredesk/internal/ratelimit" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/tag" @@ -95,6 +96,7 @@ type App struct { customAttribute *customAttribute.Manager report *report.Manager webhook *webhook.Manager + rateLimit *ratelimit.Limiter // Global state that stores data on an available app update. update *AppUpdate @@ -202,6 +204,7 @@ func main() { sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n) conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) autoassigner = initAutoAssigner(team, user, conversation) + rateLimiter = initRateLimit(rdb) ) wsHub.SetConversationStore(conversation) @@ -253,6 +256,7 @@ func main() { macro: initMacro(db, i18n), ai: initAI(db, i18n), webhook: webhook, + rateLimit: rateLimiter, } app.consts.Store(constants) diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go index 82103168..88046b0f 100644 --- a/cmd/widget_middleware.go +++ b/cmd/widget_middleware.go @@ -154,3 +154,14 @@ func getWidgetClaimsOptional(r *fastglue.Request) *Claims { } return nil } + +// rateLimitWidget applies rate limiting to widget endpoints. +func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil { + return err + } + return handler(r) + } +} diff --git a/config.sample.toml b/config.sample.toml index 90f771f7..1139c340 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -122,3 +122,8 @@ unsnooze_interval = "5m" [sla] # How often to evaluate SLA compliance for conversations evaluation_interval = "5m" + +[rate_limit] + [rate_limit.widget] + enabled = true + requests_per_minute = 100 diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 00000000..7108012b --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,96 @@ +package ratelimit + +import ( + "fmt" + "strconv" + "time" + + realip "github.com/ferluci/fast-realip" + "github.com/redis/go-redis/v9" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// Config holds rate limiting configuration +type Config struct { + Widget WidgetConfig `toml:"widget"` +} + +// WidgetConfig holds widget-specific rate limiting configuration +type WidgetConfig struct { + Enabled bool `toml:"enabled"` + RequestsPerMinute int `toml:"requests_per_minute"` +} + +// Limiter handles rate limiting using Redis +type Limiter struct { + redis *redis.Client + config Config +} + +// New creates a new rate limiter +func New(redisClient *redis.Client, config Config) *Limiter { + return &Limiter{ + redis: redisClient, + config: config, + } +} + +// CheckWidgetLimit checks if the widget request should be rate limited +func (l *Limiter) CheckWidgetLimit(ctx *fasthttp.RequestCtx) error { + if !l.config.Widget.Enabled { + return nil + } + + clientIP := realip.FromRequest(ctx) + key := fmt.Sprintf("rate_limit:widget:%s", clientIP) + + // Use sliding window approach with Redis + now := time.Now().Unix() + windowStart := now - 60 // 60 seconds window + + // Get current count in the last minute + count, err := l.redis.ZCount(ctx, key, strconv.FormatInt(windowStart, 10), "+inf").Result() + if err != nil { + // Redis is down, allow request + return nil + } + + if count >= int64(l.config.Widget.RequestsPerMinute) { + // Set rate limit headers + ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute)) + ctx.Response.Header.Set("X-RateLimit-Remaining", "0") + ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10)) + ctx.Response.Header.Set("Retry-After", "60") + + ctx.SetStatusCode(fasthttp.StatusTooManyRequests) + ctx.SetBodyString(`{"status":"error","message":"Rate limit exceeded"}`) + return fmt.Errorf("rate limit exceeded") + } + + // Add current request to the sliding window + pipe := l.redis.Pipeline() + pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now}) + pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(windowStart, 10)) + pipe.Expire(ctx, key, time.Minute*2) // Set expiry to cleanup old keys + _, err = pipe.Exec(ctx) + if err != nil { + // Redis is down, allow request + return nil + } + + // Set rate limit headers for successful requests + remaining := max(l.config.Widget.RequestsPerMinute-int(count)-1, 0) + ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute)) + ctx.Response.Header.Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) + ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10)) + + return nil +} + +// WidgetMiddleware returns a fastglue middleware for widget rate limiting +func (l *Limiter) WidgetMiddleware() func(*fastglue.Request) error { + return func(r *fastglue.Request) error { + return l.CheckWidgetLimit(r.RequestCtx) + } +}