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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ escalated.two-factor.enabled=true
escalated.widget.enabled=true
escalated.guest-access.enabled=true

# Per-client-IP limits on the public guest endpoints (429 + Retry-After when exceeded)
escalated.guest-rate-limit.enabled=true
escalated.guest-rate-limit.tickets-per-minute=5
escalated.guest-rate-limit.replies-per-minute=10

# SLA checking interval
escalated.sla.check-interval-seconds=60

Expand Down Expand Up @@ -339,6 +344,22 @@ See the [inbound email docs](https://docs.escalated.dev/inbound-email) for provi
| GET | `/tickets/{token}/replies` | View replies |
| POST | `/tickets/{token}/replies` | Add reply |

### Guest endpoint rate limits

Guest ticket creation (`POST /escalated/api/widget/tickets`) is limited to 5 per
client IP per minute and guest replies (`POST .../tickets/{token}/replies` under
both `/widget` and `/guest`) to 10, each with its own counter. A request over the
limit gets `429` with `Retry-After`. Replies are counted before the guest token is
looked up, so wrong-token guesses count too. Tune or switch it off with
`escalated.guest-rate-limit.*`; turn it off only when you already throttle these
routes upstream.

The client IP is `HttpServletRequest#getRemoteAddr()`. **Behind a reverse proxy
or load balancer, set `server.forward-headers-strategy` (and your container's
trusted proxies)**, or every guest shares the proxy's address. Counters are kept
in memory per process; a multi-instance deployment should define a shared
`dev.escalated.services.ratelimit.GuestRateLimitStore` bean (e.g. Redis-backed).

## Architecture

```
Expand Down
59 changes: 59 additions & 0 deletions src/main/java/dev/escalated/config/EscalatedProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class EscalatedProperties {
private WebhookProperties webhook = new WebhookProperties();
private WidgetProperties widget = new WidgetProperties();
private GuestAccessProperties guestAccess = new GuestAccessProperties();
private GuestRateLimitProperties guestRateLimit = new GuestRateLimitProperties();
private EmailProperties email = new EmailProperties();
private List<TicketActionProperties> ticketActions = new ArrayList<>();
private TicketSubjectsProperties ticketSubjects = new TicketSubjectsProperties();
Expand Down Expand Up @@ -127,6 +128,14 @@ public void setTicketSubjects(TicketSubjectsProperties ticketSubjects) {
this.ticketSubjects = ticketSubjects;
}

public GuestRateLimitProperties getGuestRateLimit() {
return guestRateLimit;
}

public void setGuestRateLimit(GuestRateLimitProperties guestRateLimit) {
this.guestRateLimit = guestRateLimit;
}

public NewslettersProperties getNewsletters() {
return newsletters;
}
Expand Down Expand Up @@ -319,6 +328,56 @@ public void setEnabled(boolean enabled) {
}
}

/**
* Per-client-IP rate limits on the unauthenticated guest endpoints
* ({@code POST /escalated/api/widget/tickets} and the guest reply endpoints
* under {@code /escalated/api/widget} and {@code /escalated/api/guest}).
* Every accepted guest ticket or reply writes rows and sends mail, so an
* uncapped endpoint lets anyone flood the helpdesk and the mail provider. A
* request over the limit gets {@code 429} with {@code Retry-After}.
*
* <p>The client IP is {@code HttpServletRequest#getRemoteAddr()}. Behind a
* reverse proxy or load balancer, set {@code server.forward-headers-strategy}
* and configure your container's trusted proxies, or every guest shares the
* proxy's address.
*
* <p>Counters live in a {@code GuestRateLimitStore}: in memory, per process,
* unless the host defines a shared {@code GuestRateLimitStore} bean (e.g.
* Redis-backed) for a multi-instance deployment.
*/
public static class GuestRateLimitProperties {
/** Set false only when the host already throttles these routes upstream. */
private boolean enabled = true;
/** Guest ticket submissions per IP per minute. */
private int ticketsPerMinute = 5;
/** Guest replies per IP per minute. */
private int repliesPerMinute = 10;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public int getTicketsPerMinute() {
return ticketsPerMinute;
}

public void setTicketsPerMinute(int ticketsPerMinute) {
this.ticketsPerMinute = ticketsPerMinute;
}

public int getRepliesPerMinute() {
return repliesPerMinute;
}

public void setRepliesPerMinute(int repliesPerMinute) {
this.repliesPerMinute = repliesPerMinute;
}
}

/**
* Outbound + inbound email config. {@code domain} is used for the
* right-hand side of RFC 5322 Message-IDs and signed Reply-To
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/dev/escalated/config/GuestThrottleWebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package dev.escalated.config;

import dev.escalated.controllers.widget.GuestThrottleInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* Registers the guest endpoint rate limit. It acts only on handlers marked
* {@code @GuestThrottle}, so every other request passes straight through.
*/
@Configuration(proxyBeanMethods = false)
public class GuestThrottleWebConfig implements WebMvcConfigurer {

private final GuestThrottleInterceptor guestThrottleInterceptor;

public GuestThrottleWebConfig(GuestThrottleInterceptor guestThrottleInterceptor) {
this.guestThrottleInterceptor = guestThrottleInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(guestThrottleInterceptor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public ResponseEntity<List<Reply>> replies(@PathVariable String token) {
}

@PostMapping("/tickets/{token}/replies")
// Shares the widget reply counter; counted before the token lookup.
@GuestThrottle(GuestThrottle.Scope.REPLY)
public ResponseEntity<Reply> addReply(@PathVariable String token, @RequestBody Map<String, String> body) {
Ticket ticket = ticketService.findByGuestToken(token);
return ResponseEntity.status(201).body(ticketService.addReply(
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/dev/escalated/controllers/widget/GuestThrottle.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.escalated.controllers.widget;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Marks a guest endpoint as rate-limited per client IP against the given
* counter. Enforced by {@link GuestThrottleInterceptor}, which runs before the
* request body is read and before the handler looks up a guest token.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GuestThrottle {

Scope value();

/** Each scope has its own counter per IP. */
enum Scope {
TICKET,
REPLY
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package dev.escalated.controllers.widget;

import dev.escalated.config.EscalatedProperties;
import dev.escalated.services.ratelimit.GuestRateLimitStore;
import dev.escalated.services.ratelimit.InMemoryGuestRateLimitStore;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Locale;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

/**
* Per-client-IP rate limit for the unauthenticated guest endpoints marked
* {@link GuestThrottle}. Limits come from {@code escalated.guest-rate-limit.*}
* (defaults: 5 ticket submissions and 10 replies per IP per minute), each
* scope with its own counter, over a 60-second window.
*
* <p>An interceptor runs before argument resolution, so requests with a wrong
* guest token or an unreadable body are counted too, and a token cannot be
* guessed at speed.
*
* <p>The client IP is {@link HttpServletRequest#getRemoteAddr()}. Behind a proxy
* the host must set {@code server.forward-headers-strategy} and trust its
* proxies, or every guest shares the proxy's address.
*
* <p>Counters live in the host's {@link GuestRateLimitStore} bean when it
* defines one, otherwise in memory, per process.
*/
@Component
public class GuestThrottleInterceptor implements HandlerInterceptor {

static final Duration WINDOW = Duration.ofMinutes(1);

private final ObjectProvider<EscalatedProperties> properties;
private final GuestRateLimitStore store;

public GuestThrottleInterceptor(ObjectProvider<EscalatedProperties> properties,
ObjectProvider<GuestRateLimitStore> store) {
this.properties = properties;
this.store = store.getIfAvailable(InMemoryGuestRateLimitStore::new);
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!(handler instanceof HandlerMethod method)) {
return true;
}
GuestThrottle throttle = method.getMethodAnnotation(GuestThrottle.class);
if (throttle == null) {
return true;
}

EscalatedProperties.GuestRateLimitProperties config = properties
.getIfAvailable(EscalatedProperties::new)
.getGuestRateLimit();
if (config == null || !config.isEnabled()) {
return true;
}

GuestThrottle.Scope scope = throttle.value();
int limit = Math.max(1, scope == GuestThrottle.Scope.TICKET
? config.getTicketsPerMinute()
: config.getRepliesPerMinute());
String ip = request.getRemoteAddr() == null ? "unknown" : request.getRemoteAddr();
String key = "escalated:guest:" + scope.name().toLowerCase(Locale.ROOT) + ":" + ip;

GuestRateLimitStore.Hit hit = store.increment(key, WINDOW);
if (hit.count() <= limit) {
return true;
}

long retryAfter = Math.max(1, (hit.resetsIn().toMillis() + 999) / 1000);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader(HttpHeaders.RETRY_AFTER, Long.toString(retryAfter));
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"Too many requests. Please try again later.\"}");
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public WidgetController(TicketService ticketService,
}

@PostMapping("/tickets")
@GuestThrottle(GuestThrottle.Scope.TICKET)
public ResponseEntity<Ticket> createTicket(@RequestBody Map<String, String> body) {
Ticket ticket = ticketService.create(
body.get("subject"),
Expand All @@ -54,6 +55,8 @@ public ResponseEntity<Ticket> getTicketByToken(@PathVariable String token) {
}

@PostMapping("/tickets/{token}/replies")
// Counted before the token lookup, so wrong-token guesses count too.
@GuestThrottle(GuestThrottle.Scope.REPLY)
public ResponseEntity<Reply> addReply(@PathVariable String token, @RequestBody Map<String, String> body) {
Ticket ticket = ticketService.findByGuestToken(token);
Reply reply = ticketService.addReply(ticket.getId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.escalated.services.ratelimit;

import java.time.Duration;

/**
* Where the guest endpoint counters live. The default keeps them in process
* memory; a host running several instances defines a bean of this type backed
* by something they share (e.g. Redis), and Escalated uses it instead.
*/
public interface GuestRateLimitStore {

/**
* Counts one hit against {@code key} in a fixed window of {@code window}
* that opens at the key's first hit.
*
* @return the hits counted in the current window, including this one, and
* how long until that window closes
*/
Hit increment(String key, Duration window);

/** The state of a key's window after a hit. */
record Hit(long count, Duration resetsIn) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package dev.escalated.services.ratelimit;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* The default {@link GuestRateLimitStore}: fixed windows in a concurrent map,
* per process. Expired windows are swept at most once a minute so the map does
* not grow with every address that ever called.
*/
public class InMemoryGuestRateLimitStore implements GuestRateLimitStore {

private static final Duration SWEEP_INTERVAL = Duration.ofMinutes(1);

private final Map<String, Window> windows = new ConcurrentHashMap<>();
private final Clock clock;
private volatile Instant nextSweep;

public InMemoryGuestRateLimitStore() {
this(Clock.systemUTC());
}

public InMemoryGuestRateLimitStore(Clock clock) {
this.clock = clock;
this.nextSweep = clock.instant().plus(SWEEP_INTERVAL);
}

@Override
public Hit increment(String key, Duration window) {
Instant now = clock.instant();
sweep(now);

Window current = windows.compute(key, (k, existing) ->
existing == null || !now.isBefore(existing.endsAt())
? new Window(now.plus(window), 1)
: new Window(existing.endsAt(), existing.count() + 1));

return new Hit(current.count(), Duration.between(now, current.endsAt()));
}

private void sweep(Instant now) {
if (now.isBefore(nextSweep)) {
return;
}
nextSweep = now.plus(SWEEP_INTERVAL);
windows.entrySet().removeIf(entry -> !now.isBefore(entry.getValue().endsAt()));
}

private record Window(Instant endsAt, long count) {
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ escalated.snooze.check-interval-seconds=60
escalated.webhook.max-retries=3
escalated.widget.enabled=true
escalated.guest-access.enabled=false
escalated.guest-rate-limit.enabled=true
escalated.guest-rate-limit.tickets-per-minute=5
escalated.guest-rate-limit.replies-per-minute=10
escalated.newsletters.enabled=false
escalated.newsletters.app-url=http://localhost
escalated.newsletters.default-theme=default
Expand Down
Loading
Loading