Skip to content

feat(security): rate limit the public endpoints an anonymous caller can drive - #14

Merged
JOY (JOY) merged 1 commit into
devfrom
feat/public-endpoint-rate-limiting
Sep 13, 2026
Merged

JOY (JOY) merged 1 commit into
devfrom
feat/public-endpoint-rate-limiting

Conversation

@JOY

@JOY JOY (JOY) commented Sep 13, 2026 •

Copy link
Copy Markdown

Closes SEC-08 in docs/CROVE_DESK_AUDIT.html. Depends on the trusted-proxy work in #13, which is why it comes second: the limiter keys on ctx.ClientIP(), and until #13 that value was whatever the caller put in X-Forwarded-For.

The gap

There was no rate limiting anywhere in the application. Every unauthenticated endpoint could be called in a tight loop, which made three things cheap: flooding the message upload endpoints with 20 MB bodies, spraying login and support registration, and burning LLM spend through the conversation endpoints.

What is limited

Route Budget per window Why that number
POST /api/auth/login 20 A human mistypes a password a few times
POST /api/support/auth/register 10 Registration is rare and public
POST /api/customer/session_exchange 120 Has to absorb a whole support office loading the widget from behind one NAT address
POST /api/message/upload_attachment + upload_image 30 shared One budget for both, because what matters is bytes pushed at storage, not which endpoint
POST /api/support/doc-page/feedback 20 Public write, low legitimate volume

Window is 60 seconds by default; RATE_LIMIT_ENABLED and RATE_LIMIT_WINDOW_SECONDS configure it. Limits are sized for a human driving a browser, not for a machine.

What is deliberately NOT limited

  • /api/third/* channel webhooks. A platform that receives a 429 from its webhook stops retrying and eventually disables the delivery, which takes an entire channel offline - a far worse outcome than the flood the limit was meant to stop. Those endpoints authenticate by signature instead.
  • /api/ws/* websockets, and /api/webhooks/* which are HMAC verified.
  • /api/dashboard/* - already behind AuthMiddleware, and staff sharing one office address would throttle each other.

TestNewServerLeavesWebhooksAndPublicReadsUnthrottled fires 1000 requests at the health, config, org-sync and two channel webhook routes and fails if any of them returns 429. That exemption is the part most likely to be broken by a future refactor, so it is the part with the loudest test.

Response shape

429, Retry-After in whole seconds rounded up, and an ordinary JsonResult body.

A real 429 rather than the 200-with-error-code that AuthMiddleware uses, because web/lib/api/client.ts parses the payload before it inspects response.ok and surfaces payload.message - so the localized text still reaches the user, and Retry-After is only meaningful on a 429 or 503. web/lib/api/im.ts uses the same request helper, so the widget behaves identically.

The message is error.e0354 in both backend locales and does not disclose the limit or the remaining budget, which would only tell a caller how much room they have left.

Rejections are not logged separately: requestLogMiddleware already records path, status and client address for every request, so a 429 is visible there without giving a flood a second way to fill the log.

Design notes

  • Counters are per process, and expired buckets are swept lazily from inside Allow rather than by a goroutine, so there is no background lifetime to own and no unbounded growth. Running several replicas gives each its own budget, weakening the bound by the replica count. config.example.yaml says so explicitly rather than leaving an operator to discover it. These limits exist to make flooding expensive, not to meter a quota.
  • A nil limiter allows everything, so "disabled" is expressed by handing out nil limiters instead of branching at six call sites.
  • newPublicRateLimits builds the limiters once per server in addRouter, not per request.

Tests

Fifteen new: seven for the limiter, three for the middleware, five end to end against a server built by NewServer.

TestLimiterCountsExactlyUnderConcurrency runs 16 goroutines making 3200 calls against one key with a limit of 1000 and asserts exactly 1000 were allowed.

One caveat, stated rather than hidden: -race could not be used, because this repository builds with CGO disabled and go test -race requires cgo. The concurrency test still has teeth - an unguarded concurrent map write panics rather than merely miscounting - but it is not a substitute for the race detector.

Retry-After rounding was caught by my own test: the first implementation did int(seconds)+1, which returns 61 for a 60 second window whenever the clock has not ticked between requests. It now rounds up properly.

Verification

go build -tags dev ./... and go vet -tags dev ./... clean. go test -count=1 -tags dev ./internal/... passes across 49 packages - broader than what CI runs, which covers services, repositories, pkg, oidcclient, migration, builders, bootstrap and handlers.

Not in this pull request

The upstream port. This is upstream code and belongs in huabeitech/agent-desk too, but it cannot be replayed the way the earlier upload fix was: internal/pkg/config/config.go carries fork-only configuration on dev, and internal/bootstrap/server.go on dev already contains the storage hardening still sitting in the open upstream PR huabeitech#40. Porting needs the hunks applied to upstream's versions by hand, which is one piece of work covering SEC-26, SEC-09 and SEC-08 together.


Note

Medium Risk
Touches hot unauthenticated paths (login, uploads, widget session exchange); misconfigured trusted proxies would weaken or mis-key limits, and per-replica counters only raise the cost of abuse rather than enforcing a global quota.

Overview
Adds per-client-IP rate limiting on abuse-prone public routes, keyed off ctx.ClientIP() (so it depends on trusted-proxy configuration being correct).

What gets limited (separate in-process fixed-window budgets, default 60s window): POST /api/auth/login, support registration, widget session_exchange, shared budget for message image/attachment uploads, and doc-page feedback. Limits are built once at router setup and applied via new middleware.RateLimit, which returns HTTP 429 with Retry-After (seconds, rounded up) and localized error.e0354 in the usual JSON envelope.

Configuration: new server.rateLimit / RATE_LIMIT_ENABLED and RATE_LIMIT_WINDOW_SECONDS (on by default; disabling yields nil limiters so routes stay unchanged). Example env and YAML document that counters are per process (weaker with multiple replicas) and that channel webhooks, /api/webhooks, websockets, and the authenticated dashboard are intentionally exempt so platforms do not get 429s on webhook delivery.

Implementation: new internal/pkg/ratelimit in-memory limiter with lazy bucket sweep; bootstrap integration tests assert throttling, per-IP isolation, configurability, disable path, and that health/config/org-sync and third-party webhooks never return 429.

Reviewed by Cursor Bugbot for commit 61832f4. Configure here.

…an drive

There was no rate limiting anywhere in the application. Every unauthenticated
endpoint could be called in a tight loop, which made three things cheap: flooding
the message upload endpoints with 20 MB bodies, spraying login and support
registration, and burning LLM spend through the conversation endpoints.

Add a small in-process fixed-window limiter and attach it to six public routes:
login (20 per window), support registration (10), the widget session exchange
(120), message attachment and image upload (30, shared between the two), and
documentation feedback (20). The window is 60 seconds by default, and both it and
the on/off switch are configurable.

The limits are sized for a human driving a browser, not for a machine. The session
exchange budget in particular has to absorb a whole support office loading the
widget from behind one NAT address, which is why it sits an order of magnitude
above the others.

Deliberately not limited:

- /api/third/* channel webhooks. A platform that receives a 429 from its webhook
  stops retrying and eventually disables the delivery, which takes an entire
  channel offline - a far worse outcome than the flood the limit was meant to
  stop. Those endpoints authenticate by signature instead.
- /api/ws/* websockets, and /api/webhooks/* which are HMAC verified.
- /api/dashboard/* - already behind AuthMiddleware, and staff sharing one office
  address would throttle each other.

Rejections return 429 with a Retry-After header and an ordinary JsonResult body.
The status is a real 429 rather than the 200-with-error-code the auth middleware
uses, because web/lib/api/client.ts parses the payload before it inspects
response.ok and surfaces payload.message, so the localized text still reaches the
user - and Retry-After is only meaningful on a 429 or 503. The message is
error.e0354 in both backend locales and does not disclose the limit or the
remaining budget, which would only tell a caller how much room they have left.

Retry-After rounds the remaining window up rather than down. Telling a caller to
come back sooner than the window actually resets just earns another 429.

Rejections are not logged separately. requestLogMiddleware already records path,
status and client address for every request, so a 429 is visible there without
giving a flood a second way to fill the log.

The limiter keys on ctx.ClientIP(), which is only trustworthy because of the
trusted-proxy configuration that landed immediately before this. Without it a
caller would pick their own bucket with an X-Forwarded-For header.

Counters are per process, and expired buckets are swept lazily from inside Allow
rather than by a goroutine, so there is no background lifetime to own and no
unbounded growth. Running several replicas gives each its own budget, weakening
the bound by the replica count; config.example.yaml says so explicitly. These
limits exist to make flooding expensive, not to meter a quota.

Tests: seven for the limiter, including an exact-count check across 16 goroutines
making 3200 calls against one key; three for the middleware, covering the 429
shape, per-address isolation and a nil limiter allowing everything; and five end
to end against a server built by NewServer, including one that fires 1000 requests
at the health, config, org-sync and two channel webhook routes and fails if any of
them returns 429.

The concurrency test could not be run under -race: this repository builds with CGO
disabled and go test -race requires cgo. It still has teeth, because an unguarded
concurrent map write panics rather than merely miscounting.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: be5e7d7b-b96e-4bb5-9253-a2cd0bb22812

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Sep 13, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_86c1b6fd-b6c2-4d8a-97b4-83b4ff5eccd7)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an in-process, fixed-window rate limiting mechanism to protect public, unauthenticated endpoints from abuse. It includes a new ratelimit package, a RateLimit middleware, configuration updates, and comprehensive tests. Feedback focuses on optimizing the rate limiter's memory and CPU usage during floods by recreating the map in sweep instead of using delete, and avoiding unnecessary map allocations in New when the limiter is disabled.

Comment on lines +91 to +101
func (l *Limiter) sweep(now time.Time) {
if !l.sweptAt.IsZero() && now.Sub(l.sweptAt) < l.window {
return
}
for key, current := range l.buckets {
if !now.Before(current.resetAt) {
delete(l.buckets, key)
}
}
l.sweptAt = now
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Go, deleting keys from a map using delete does not shrink the underlying bucket array or release its memory. If the server experiences a flood of requests from many unique IP addresses (e.g., during a DDoS or scanning event), the buckets map will grow extremely large. Even after the keys expire and are deleted by sweep, the map will retain its peak size, and subsequent iterations over the map during sweep will still scan all empty buckets, causing CPU spikes on the hot path of Allow.

To prevent memory bloat and slow iterations, we can recreate the map with only the active (non-expired) buckets during the sweep. Since sweep runs at most once per window, this overhead is highly amortized and keeps the map size bounded to only active clients.

Suggested change
func (l *Limiter) sweep(now time.Time) {
if !l.sweptAt.IsZero() && now.Sub(l.sweptAt) < l.window {
return
}
for key, current := range l.buckets {
if !now.Before(current.resetAt) {
delete(l.buckets, key)
}
}
l.sweptAt = now
}
func (l *Limiter) sweep(now time.Time) {
if !l.sweptAt.IsZero() && now.Sub(l.sweptAt) < l.window {
return
}
active := make(map[string]bucket)
for key, current := range l.buckets {
if now.Before(current.resetAt) {
active[key] = current
}
}
l.buckets = active
l.sweptAt = now
}

Comment on lines +37 to +42
func New(limit int, window time.Duration) *Limiter {
if limit <= 0 || window <= 0 {
return &Limiter{limit: limit, window: window, buckets: make(map[string]bucket)}
}
return &Limiter{limit: limit, window: window, buckets: make(map[string]bucket)}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The two branches in New are identical and both allocate the buckets map. When limit <= 0 or window <= 0, the limiter is disabled and Allow returns early without ever accessing buckets. We can avoid this unnecessary allocation and simplify the code by only initializing the map when the limiter is actually enabled.

Suggested change
func New(limit int, window time.Duration) *Limiter {
if limit <= 0 || window <= 0 {
return &Limiter{limit: limit, window: window, buckets: make(map[string]bucket)}
}
return &Limiter{limit: limit, window: window, buckets: make(map[string]bucket)}
}
func New(limit int, window time.Duration) *Limiter {
if limit <= 0 || window <= 0 {
return &Limiter{limit: limit, window: window}
}
return &Limiter{limit: limit, window: window, buckets: make(map[string]bucket)}
}

@JOY
JOY (JOY) merged commit 9f8ab4b into dev Sep 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant