feat(security): add per-IP rate limiting to account API endpoints
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
Adds a fixed-window rate limiter (default 60 req/60s per IP) to all public account endpoints (AccountResource, ChallengeResource, OfficialChallengeResource). Implemented as a JAX-RS @NameBinding ContainerRequestFilter in the shared security module. IP is resolved from X-Forwarded-For > X-Real-IP > "unknown". Load-test traffic can bypass via X-Gatling-Secret header matched against the optional nowchess.rate-limit.gatling-secret config. Exceeded requests receive HTTP 429 with a warn-level log. Closes NCS-65 https://knockoutwhist.youtrack.cloud/issue/NCS-65 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.security;
|
||||
|
||||
import jakarta.ws.rs.NameBinding;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@NameBinding
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
public @interface RateLimited {}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.nowchess.security
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
|
||||
import jakarta.ws.rs.core.Response
|
||||
import jakarta.ws.rs.ext.Provider
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
import java.time.{Duration, Instant}
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@Provider
|
||||
@RateLimited
|
||||
@ApplicationScoped
|
||||
class RateLimitFilter extends ContainerRequestFilter:
|
||||
|
||||
@ConfigProperty(name = "nowchess.rate-limit.enabled", defaultValue = "true")
|
||||
// scalafix:off DisableSyntax.var
|
||||
var enabled: Boolean = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.rate-limit.requests-per-window", defaultValue = "60")
|
||||
var requestsPerWindow: Long = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.rate-limit.window-seconds", defaultValue = "60")
|
||||
var windowSeconds: Long = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.rate-limit.gatling-secret")
|
||||
var gatlingSecret: Optional[String] = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val log = Logger.getLogger(classOf[RateLimitFilter])
|
||||
private val counters = new ConcurrentHashMap[String, RateWindow]()
|
||||
|
||||
override def filter(ctx: ContainerRequestContext): Unit =
|
||||
val ip = clientIp(ctx)
|
||||
if enabled && !isGatlingRequest(ctx) && isOverLimit(ip) then
|
||||
log.warnf("Rate limit exceeded for IP %s on %s %s", ip, ctx.getMethod, ctx.getUriInfo.getPath)
|
||||
ctx.abortWith(Response.status(429).build())
|
||||
|
||||
private def isGatlingRequest(ctx: ContainerRequestContext): Boolean =
|
||||
gatlingSecret.isPresent &&
|
||||
Option(ctx.getHeaderString("X-Gatling-Secret")).contains(gatlingSecret.get())
|
||||
|
||||
private def isOverLimit(ip: String): Boolean =
|
||||
val now = Instant.now()
|
||||
val window = counters.compute(
|
||||
ip,
|
||||
(_, current) =>
|
||||
// scalafix:off DisableSyntax.null
|
||||
if current == null || isExpired(current.start, now) then RateWindow(now, new AtomicLong(0))
|
||||
// scalafix:on DisableSyntax.null
|
||||
else current,
|
||||
)
|
||||
window.count.incrementAndGet() > requestsPerWindow
|
||||
|
||||
private def clientIp(ctx: ContainerRequestContext): String =
|
||||
Option(ctx.getHeaderString("X-Forwarded-For"))
|
||||
.map(_.split(",").head.trim)
|
||||
.orElse(Option(ctx.getHeaderString("X-Real-IP")))
|
||||
.getOrElse("unknown")
|
||||
|
||||
private def isExpired(start: Instant, now: Instant): Boolean =
|
||||
Duration.between(start, now).getSeconds >= windowSeconds
|
||||
|
||||
private final case class RateWindow(start: Instant, count: AtomicLong)
|
||||
Reference in New Issue
Block a user