Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ PORT=8083
# TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12"
TRUSTED_PLATFORM=cloudflare

# Rate limiting for the public, unauthenticated endpoints (login, support
# registration, widget session exchange, message uploads, doc feedback). Enabled
# by default; the counters are per process. Channel webhooks, websockets and the
# authenticated dashboard are deliberately exempt.
# RATE_LIMIT_ENABLED=true
# RATE_LIMIT_WINDOW_SECONDS=60

# Database Configuration
# Driver options: sqlite, mysql, postgres
# Supabase PostgreSQL (DOS):
Expand Down
15 changes: 15 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ server:
# header name) when an edge overwrites rather than appends the real client
# address. When set it takes precedence over X-Forwarded-For entirely.
trustedPlatform: ""
rateLimit:
# Bounds how often one client address may call the public, unauthenticated
# endpoints: login, support registration, the widget session exchange, message
# uploads and documentation feedback. Enabled by default.
#
# Channel webhooks under /api/third, the websocket routes, /api/webhooks and
# the whole authenticated dashboard are deliberately NOT covered. A platform
# that receives a 429 from its webhook stops retrying and eventually disables
# the delivery, which takes an entire channel offline.
#
# The counters live in this process. Running several replicas gives each one
# its own budget, which weakens the bound by the replica count; these limits
# exist to make flooding expensive, not to meter a quota.
enabled: true
windowSeconds: 60
cors:
# Browser CORS allowlist. In production, replace this with the actual frontend or embedded-site domains, such as https://support.example.com.
# Leave it empty to reject cross-origin browser requests. Same-origin and non-browser calls are still supported.
Expand Down
75 changes: 65 additions & 10 deletions internal/bootstrap/routes.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,66 @@
package bootstrap

import (
"time"

"agent-desk/internal/handlers/api"
"agent-desk/internal/handlers/dashboard"
"agent-desk/internal/handlers/third"
"agent-desk/internal/middleware"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/ratelimit"

"github.com/gin-gonic/gin"
)

func registerApiAuthRoutes(group *gin.RouterGroup) {
group.POST("/login", api.Login)
// publicRateLimits holds one limiter per abuse-prone unauthenticated endpoint.
// They are built once per server rather than per request, and the middleware keys
// them by client address.
//
// Channel webhooks under /api/third, the websocket routes and /api/webhooks are
// deliberately absent. A platform that receives a 429 from its webhook stops
// retrying and eventually disables the delivery, which would take a whole channel
// offline; those endpoints authenticate by signature instead. The dashboard is
// absent too - it is already behind AuthMiddleware, and staff sharing one office
// address would throttle each other.
type publicRateLimits struct {
login *ratelimit.Limiter
supportRegister *ratelimit.Limiter
sessionExchange *ratelimit.Limiter
upload *ratelimit.Limiter
docFeedback *ratelimit.Limiter
}

// Limits are sized for a human driving a browser, not for a machine. They are
// deliberately generous: the point is to make flooding expensive, not to police
// legitimate use. The session exchange budget in particular has to absorb a whole
// support office loading the widget from behind one NAT address.
const (
limitLogin = 20
limitSupportRegister = 10
limitSessionExchange = 120
limitUpload = 30
limitDocFeedback = 20
)

func newPublicRateLimits(cfg config.RateLimitConfig) publicRateLimits {
if !cfg.IsEnabled() {
// Every field stays nil and a nil limiter allows everything, so "disabled"
// needs no branch at any call site.
return publicRateLimits{}
}
window := time.Duration(cfg.WindowSecondsOrDefault()) * time.Second
return publicRateLimits{
login: ratelimit.New(limitLogin, window),
supportRegister: ratelimit.New(limitSupportRegister, window),
sessionExchange: ratelimit.New(limitSessionExchange, window),
upload: ratelimit.New(limitUpload, window),
docFeedback: ratelimit.New(limitDocFeedback, window),
}
}

func registerApiAuthRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/login", middleware.RateLimit(limits.login), api.Login)
group.POST("/logout", api.Logout)
group.GET("/profile", api.Profile)
group.POST("/profile/update", api.UpdateProfile)
Expand Down Expand Up @@ -37,8 +88,8 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) {
group.POST("/ecosystem", api.OrgSyncWebhook)
}

func registerApiCustomerRoutes(group *gin.RouterGroup) {
group.POST("/session_exchange", api.CustomerPostSession_exchange)
func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange)
}

func registerApiConversationRoutes(group *gin.RouterGroup) {
Expand All @@ -47,22 +98,26 @@ func registerApiConversationRoutes(group *gin.RouterGroup) {
group.POST("/create_or_match", api.ConversationPostCreate_or_match)
}

func registerApiMessageRoutes(group *gin.RouterGroup) {
func registerApiMessageRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.Any("/list", api.MessageAnyList)
group.POST("/read", api.MessagePostRead)
group.POST("/send", api.MessagePostSend)
group.POST("/upload_attachment", api.MessagePostUpload_attachment)
group.POST("/upload_image", api.MessagePostUpload_image)
// One shared budget for both upload routes: what matters is how many bytes an
// unauthenticated caller can push at the storage layer, not which of the two
// endpoints they used.
uploadLimit := middleware.RateLimit(limits.upload)
group.POST("/upload_attachment", uploadLimit, api.MessagePostUpload_attachment)
group.POST("/upload_image", uploadLimit, api.MessagePostUpload_image)
}

func registerApiSupportRoutes(group *gin.RouterGroup) {
func registerApiSupportRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.GET("/config", api.SupportConfigGetConfig)
group.POST("/auth/register", api.SupportAuthPostRegister)
group.POST("/auth/register", middleware.RateLimit(limits.supportRegister), api.SupportAuthPostRegister)
group.GET("/me", api.SupportGetMe)
group.Any("/doc-page/list", api.DocPageAnyList)
group.GET("/doc-page/navigation", api.DocPageGetNavigation)
group.GET("/doc-page/:id", api.DocPageGetBy)
group.POST("/doc-page/feedback", api.DocPagePostFeedback)
group.POST("/doc-page/feedback", middleware.RateLimit(limits.docFeedback), api.DocPagePostFeedback)
group.Any("/community/categories/list", api.CategoryAnyList)
group.Any("/community/posts/list", api.PostAnyList)
group.GET("/community/posts/:id", api.PostGetBy)
Expand Down
10 changes: 6 additions & 4 deletions internal/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,19 @@ func isWebsocketUpgrade(ctx *gin.Context) bool {
}

func addRouter(app *gin.Engine) {
limits := newPublicRateLimits(config.Current().Server.RateLimit)

app.Any("/api/mcp", gin.WrapH(mcps.NewHTTPHandler()))

apiGroup := app.Group("/api")
apiGroup.GET("/health", api.Health)
apiGroup.GET("/config", api.PublicConfig)
registerApiAuthRoutes(apiGroup.Group("/auth"))
registerApiAuthRoutes(apiGroup.Group("/auth"), limits)
registerApiChannelRoutes(apiGroup.Group("/channel"))
registerApiCustomerRoutes(apiGroup.Group("/customer"))
registerApiCustomerRoutes(apiGroup.Group("/customer"), limits)
registerApiConversationRoutes(apiGroup.Group("/conversation", middleware.ExternalUserMiddleware))
registerApiMessageRoutes(apiGroup.Group("/message", middleware.ExternalUserMiddleware))
registerApiSupportRoutes(apiGroup.Group("/support"))
registerApiMessageRoutes(apiGroup.Group("/message", middleware.ExternalUserMiddleware), limits)
registerApiSupportRoutes(apiGroup.Group("/support"), limits)
registerApiWebhookRoutes(apiGroup.Group("/webhooks"))

wsGroup := app.Group("/api/ws")
Expand Down
162 changes: 162 additions & 0 deletions internal/bootstrap/server_ratelimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package bootstrap

import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"

"agent-desk/internal/pkg/config"

"github.com/gin-gonic/gin"
)

func parseInt(t *testing.T, value string) int {
t.Helper()
parsed, err := strconv.Atoi(value)
if err != nil {
t.Fatalf("%q is not an integer", value)
}
return parsed
}

// setRateLimitTestConfig disables password login so /api/auth/login returns
// before it reaches the database. The limiter runs ahead of the handler either
// way, which is what these tests are about.
func setRateLimitTestConfig(rateLimit config.RateLimitConfig) {
disabled := false
config.SetCurrent(&config.Config{
Server: config.ServerConfig{
RateLimit: rateLimit,
CORS: config.CORSConfig{AllowedOrigins: []string{}},
},
Auth: config.AuthConfig{PasswordLoginEnabled: &disabled},
Storage: config.StorageConfig{Local: config.LocalStorageConfig{Root: "storage", BaseURL: "/storage"}},
})
}

func postJSON(app *gin.Engine, path, clientIP string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"username":"admin","password":"secret"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = clientIP + ":52000"
app.ServeHTTP(rec, req)
return rec
}

func TestNewServerRateLimitsThePublicLoginEndpoint(t *testing.T) {
setRateLimitTestConfig(config.RateLimitConfig{})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

for i := 1; i <= limitLogin; i++ {
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
t.Fatalf("request %d was throttled although the login limit is %d per window", i, limitLogin)
}
}
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("request %d got status %d, want 429", limitLogin+1, rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("the 429 carried no Retry-After header")
}

// A second address must keep working, otherwise one attacker could lock the
// login page for every user behind their own NAT.
if rec := postJSON(app, "/api/auth/login", "198.51.100.9"); rec.Code == http.StatusTooManyRequests {
t.Fatal("an unrelated address was throttled by another address's requests")
}
}

func TestNewServerRateLimitsTheSupportRegistrationEndpoint(t *testing.T) {
setRateLimitTestConfig(config.RateLimitConfig{})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

for i := 1; i <= limitSupportRegister; i++ {
postJSON(app, "/api/support/auth/register", "203.0.113.7")
}
if rec := postJSON(app, "/api/support/auth/register", "203.0.113.7"); rec.Code != http.StatusTooManyRequests {
t.Fatalf("registration attempt %d got status %d, want 429", limitSupportRegister+1, rec.Code)
}
}

// TestNewServerLeavesWebhooksAndPublicReadsUnthrottled guards the exemption that
// matters most. A channel platform that receives a 429 from its webhook stops
// retrying and eventually disables the delivery, which takes a whole channel
// offline - a far worse outcome than the flood the limit was meant to stop.
func TestNewServerLeavesWebhooksAndPublicReadsUnthrottled(t *testing.T) {
setRateLimitTestConfig(config.RateLimitConfig{})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

requests := []struct {
method string
path string
}{
{http.MethodGet, "/api/health"},
{http.MethodGet, "/api/config"},
{http.MethodPost, "/api/webhooks/org-sync"},
{http.MethodGet, "/api/third/telegram/webhook"},
{http.MethodPost, "/api/third/whatsapp/webhook"},
}
for _, r := range requests {
for i := 0; i < 200; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(r.method, r.path, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "203.0.113.7:52000"
app.ServeHTTP(rec, req)
// Any status is acceptable here except 429: these routes either
// succeed, fail validation, or error out, but they must never be
// throttled.
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("%s %s returned 429 on request %d; this route must stay unthrottled", r.method, r.path, i+1)
}
}
}
}

func TestNewServerRateLimitCanBeDisabled(t *testing.T) {
disabled := false
setRateLimitTestConfig(config.RateLimitConfig{Enabled: &disabled})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

for i := 0; i < limitLogin*4; i++ {
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
t.Fatalf("request %d was throttled although rate limiting is disabled", i+1)
}
}
}

func TestNewServerRateLimitWindowIsConfigurable(t *testing.T) {
setRateLimitTestConfig(config.RateLimitConfig{WindowSeconds: 3600})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

for i := 1; i <= limitLogin; i++ {
postJSON(app, "/api/auth/login", "203.0.113.7")
}
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("got status %d, want 429", rec.Code)
}
if retryAfter := rec.Header().Get("Retry-After"); retryAfter == "" {
t.Fatal("no Retry-After header")
} else if seconds := parseInt(t, retryAfter); seconds < 3500 || seconds > 3600 {
t.Errorf("Retry-After = %s, want roughly the configured 3600 second window", retryAfter)
}
}
56 changes: 56 additions & 0 deletions internal/middleware/ratelimit_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package middleware

import (
"net/http"
"strconv"
"time"

"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/ratelimit"

"github.com/gin-gonic/gin"
)

// rateLimitMessageKey is the localized explanation returned with a 429. It
// deliberately does not disclose the limit or the remaining budget, which would
// only tell a caller how much room they have left.
const rateLimitMessageKey = "error.e0354"

// RateLimit bounds how often one client address may call a single public
// endpoint.
//
// The address comes from ctx.ClientIP(), which is only meaningful because
// NewServer configures Gin's trusted proxies and platform before any middleware
// is registered. Without that a caller picks their own bucket by setting
// X-Forwarded-For, and the limit is decoration.
//
// A nil limiter allows everything, so "disabled" is expressed by handing out nil
// limiters rather than by branching at every call site.
//
// Rejections are not logged here. requestLogMiddleware already records the path,
// status and client address of every request, so a 429 is visible there without
// giving a flood a second way to fill the log.
func RateLimit(limiter *ratelimit.Limiter) gin.HandlerFunc {
return func(ctx *gin.Context) {
allowed, retryAfter := limiter.Allow(ctx.ClientIP())
if allowed {
ctx.Next()
return
}
// Retry-After is a whole number of seconds, so round up: telling a caller
// to come back sooner than the window actually resets just earns another
// 429. Rounding down and adding one overshoots when the remaining time is
// already an exact number of seconds.
seconds := int64(retryAfter / time.Second)
if time.Duration(seconds)*time.Second < retryAfter {
seconds++
}
if seconds < 1 {
seconds = 1
}
ctx.Header("Retry-After", strconv.FormatInt(seconds, 10))
httpx.WriteHttpStatusJSON(ctx, http.StatusTooManyRequests, errorsx.InvalidParamI18n(rateLimitMessageKey))
ctx.Abort()
}
}
Loading
Loading