Skip to content

feat: rate-limit guest ticket creation and replies per IP - #96

Merged
mpge merged 2 commits into
mainfrom
feat/guest-endpoint-throttle
Oct 5, 2026
Merged

mpge merged 2 commits into
mainfrom
feat/guest-endpoint-throttle

Conversation

@mpge

@mpge mpge commented Oct 5, 2026

Copy link
Copy Markdown
Member

Port of escalated-dev/escalated-nestjs#130 (reference).

Behaviour

  • New properties escalated.guest-rate-limit.enabled (default true), .tickets-per-minute (default 5), .replies-per-minute (default 10).
  • POST /escalated/api/widget/tickets and the guest reply endpoints (POST /escalated/api/widget/tickets/{token}/replies and POST /escalated/api/guest/tickets/{token}/replies) are limited per client IP over a 60s window. Tickets and replies have separate counters; the two reply routes share the reply counter.
  • The check is a HandlerInterceptor acting on @GuestThrottle handlers. It runs before argument resolution and before the handler looks up the guest token, so wrong-token replies (and unreadable bodies) count.
  • Over the limit: 429 with Retry-After and {"error":"Too many requests. Please try again later."}.
  • Mechanism: Spring MVC has no built-in limiter and the repo does not depend on Bucket4j, so this is a small in-memory fixed-window store (InMemoryGuestRateLimitStore, expired windows swept once a minute). No new dependency.
  • Pluggable counter store: if the host defines a GuestRateLimitStore bean (e.g. Redis-backed), the interceptor uses it instead.
  • Docs (README, properties Javadoc, interceptor Javadoc): behind a proxy, set server.forward-headers-strategy and trust your proxies, or every guest shares one IP.

Differences from the reference

  • A wrong guest token here raises EntityNotFoundException from TicketService.findByGuestToken, which no handler maps to a status (a separate, pre-existing gap). The wrong-token test stubs the service to answer 404, so it expects 404, 404, 429 rather than 403, 403, 429.
  • This port had no per-email guest limit to keep as a second layer.
  • Fixed window rather than @nestjs/throttler's block duration; Retry-After is the time until the window closes.

Tests

Written first; 6 of the 10 GuestThrottleTest cases failed before the interceptor existed (no 429 returned).

  • GuestThrottleTest (MockMvc over the widget + guest controllers): 6th ticket -> 429 (service called 5 times); Retry-After in 1..60; 11th reply -> 429; wrong-token replies count (limit 2: 404, 404, 429); /guest replies share the reply counter; tickets and replies separate; IPs separate; configured limit honoured (2: 201, 201, 429); disabled -> never 429; defaults 5/10/enabled.
  • GuestThrottleInterceptorTest: a host-defined GuestRateLimitStore gets the escalated:guest:reply:<ip> key with a 60s window; the in-memory window resets once it closes.
  • SecurityWiringTest$GuestEndpoints: in a booted host with the real security chain, the limit is live (limit 1: 400, 429).

Results

  • ./gradlew test (H2): 376 tests, 0 failures, 0 errors, 9 skipped (skips are pre-existing)
  • ./gradlew checkstyleMain checkstyleTest: BUILD SUCCESSFUL; no warnings in any file this PR touches (the existing warnings are elsewhere)

mpge added 2 commits October 4, 2026 21:40
escalated.guest-rate-limit.{enabled,tickets-per-minute,replies-per-minute}
(defaults true/5/10) and a GuestRateLimitStore with an in-memory fixed-window
default. A host bean of that type replaces it for multi-instance deployments.
A HandlerInterceptor enforces @GuestThrottle before argument resolution, so
wrong-token replies and unreadable bodies are counted. Over the limit the
request gets 429 with Retry-After. The widget and guest-access reply routes
share the reply counter.

Mirrors escalated-dev/escalated-nestjs#130.
@mpge

mpge commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Once #97 lands, unknown guest tokens get a real 403 (matching the NestJS reference) instead of an unmapped EntityNotFoundException. At that point, repliesWithAWrongGuestToken_areCounted here can drop its ResponseStatusException(404) stub, hit the real endpoint and expect 403, 403, 429.

@mpge
mpge merged commit 96a102f into main Oct 5, 2026
4 checks passed
@mpge mpge mentioned this pull request Oct 8, 2026
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