From 1ae455eb99ee59645a6a068354026113afed006f Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 2 Jun 2026 15:03:16 +0200 Subject: [PATCH] feat(security): add per-IP rate limiting to account API endpoints 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 --- .../src/main/resources/application.yml | 9 ++ .../account/resource/AccountResource.scala | 2 + .../account/resource/ChallengeResource.scala | 2 + .../resource/OfficialChallengeResource.scala | 2 + .../src/test/resources/application.yml | 14 +++ .../resource/RateLimitFilterTest.scala | 89 +++++++++++++++++++ .../de/nowchess/security/RateLimited.java | 12 +++ .../nowchess/security/RateLimitFilter.scala | 69 ++++++++++++++ 8 files changed, 199 insertions(+) create mode 100644 modules/account/src/test/scala/de/nowchess/account/resource/RateLimitFilterTest.scala create mode 100644 modules/security/src/main/java/de/nowchess/security/RateLimited.java create mode 100644 modules/security/src/main/scala/de/nowchess/security/RateLimitFilter.scala diff --git a/modules/account/src/main/resources/application.yml b/modules/account/src/main/resources/application.yml index bce9bfe..b851d46 100644 --- a/modules/account/src/main/resources/application.yml +++ b/modules/account/src/main/resources/application.yml @@ -30,8 +30,15 @@ nowchess: prefix: nowchess internal: secret: 123abc + rate-limit: + enabled: true + requests-per-window: 60 + window-seconds: 60 "%test": + nowchess: + rate-limit: + enabled: false quarkus: datasource: db-kind: h2 @@ -89,6 +96,8 @@ nowchess: prefix: ${REDIS_PREFIX:nowchess} internal: secret: ${INTERNAL_SECRET} + rate-limit: + gatling-secret: ${GATLING_SECRET} mp: jwt: verify: diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala index df4d367..188070b 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount} import de.nowchess.account.dto.* import de.nowchess.account.error.AccountError import de.nowchess.account.service.AccountService +import de.nowchess.security.RateLimited import jakarta.annotation.security.RolesAllowed import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @@ -16,6 +17,7 @@ import java.util.UUID @Path("/api/account") @ApplicationScoped +@RateLimited @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) class AccountResource: diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala index 6d77005..fba6629 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala @@ -3,6 +3,7 @@ package de.nowchess.account.resource import de.nowchess.account.dto.* import de.nowchess.account.error.ChallengeError import de.nowchess.account.service.ChallengeService +import de.nowchess.security.RateLimited import jakarta.annotation.security.RolesAllowed import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @@ -16,6 +17,7 @@ import java.util.UUID @Path("/api/challenge") @ApplicationScoped @RolesAllowed(Array("**")) +@RateLimited @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) class ChallengeResource: diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala index e82582c..c86d848 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala @@ -3,6 +3,7 @@ package de.nowchess.account.resource import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo} import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse} import de.nowchess.account.service.{AccountService, EventPublisher} +import de.nowchess.security.RateLimited import jakarta.annotation.security.RolesAllowed import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @@ -19,6 +20,7 @@ import java.util.concurrent.ThreadLocalRandom @Path("/api/challenge/official") @ApplicationScoped @RolesAllowed(Array("**")) +@RateLimited @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) class OfficialChallengeResource: diff --git a/modules/account/src/test/resources/application.yml b/modules/account/src/test/resources/application.yml index 82fe950..543a960 100644 --- a/modules/account/src/test/resources/application.yml +++ b/modules/account/src/test/resources/application.yml @@ -34,3 +34,17 @@ nowchess: secret: test-secret auth: enabled: false + rate-limit: + enabled: false + +"%rate-limit-test": + nowchess: + internal: + secret: test-secret + auth: + enabled: false + rate-limit: + enabled: true + requests-per-window: 3 + window-seconds: 60 + gatling-secret: gatling-test-secret diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/RateLimitFilterTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/RateLimitFilterTest.scala new file mode 100644 index 0000000..27450da --- /dev/null +++ b/modules/account/src/test/scala/de/nowchess/account/resource/RateLimitFilterTest.scala @@ -0,0 +1,89 @@ +package de.nowchess.account.resource + +import de.nowchess.security.RateLimitFilter +import jakarta.ws.rs.container.ContainerRequestContext +import jakarta.ws.rs.core.Response +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.* + +import java.util.Optional + +class RateLimitFilterTest: + + private def newFilter(limit: Long = 2L): RateLimitFilter = + val f = new RateLimitFilter() + f.enabled = true + f.requestsPerWindow = limit + f.windowSeconds = 60 + f.gatlingSecret = Optional.of("gatling-secret") + f + + private def ctx(ip: String = "10.0.0.1", gatlingSecret: String = "") = + val c = mock(classOf[ContainerRequestContext]) + when(c.getHeaderString("X-Forwarded-For")).thenReturn(ip) + if gatlingSecret.nonEmpty then when(c.getHeaderString("X-Gatling-Secret")).thenReturn(gatlingSecret) + c + + @Test + def allowsRequestsUpToLimit(): Unit = + val filter = newFilter() + for _ <- 1 to 2 do + val c = ctx() + filter.filter(c) + verify(c, never()).abortWith(any()) + + @Test + def blocks429WhenLimitExceeded(): Unit = + val filter = newFilter(limit = 2L) + filter.filter(ctx("1.2.3.4")) + filter.filter(ctx("1.2.3.4")) + val c = ctx("1.2.3.4") + filter.filter(c) + val captor = ArgumentCaptor.forClass(classOf[Response]) + verify(c).abortWith(captor.capture()) + assertEquals(429, captor.getValue.getStatus) + + @Test + def gatlingSecretBypasses(): Unit = + val filter = newFilter(limit = 1L) + for _ <- 1 to 5 do + val c = ctx("1.2.3.5", "gatling-secret") + filter.filter(c) + verify(c, never()).abortWith(any()) + + @Test + def emptyGatlingSecretDisablesGatlingBypass(): Unit = + val filter = newFilter(limit = 1L) + filter.gatlingSecret = Optional.empty() + filter.filter(ctx("2.2.2.2", "gatling-secret")) + val c = ctx("2.2.2.2", "gatling-secret") + filter.filter(c) + verify(c).abortWith(any()) + + @Test + def doesNothingWhenDisabled(): Unit = + val filter = newFilter() + filter.enabled = false + for _ <- 1 to 5 do + val c = ctx() + filter.filter(c) + verify(c, never()).abortWith(any()) + + @Test + def tracksDifferentIpsSeparately(): Unit = + val filter = newFilter(limit = 1L) + for ip <- List("10.1.1.1", "10.1.1.2", "10.1.1.3") do + val c = ctx(ip) + filter.filter(c) + verify(c, never()).abortWith(any()) + + @Test + def usesXForwardedForFirstSegment(): Unit = + val filter = newFilter(limit = 1L) + filter.filter(ctx("203.0.113.1, 10.0.0.1")) + val c = ctx("203.0.113.1, 10.0.0.1") + filter.filter(c) + verify(c).abortWith(any()) diff --git a/modules/security/src/main/java/de/nowchess/security/RateLimited.java b/modules/security/src/main/java/de/nowchess/security/RateLimited.java new file mode 100644 index 0000000..3e460e8 --- /dev/null +++ b/modules/security/src/main/java/de/nowchess/security/RateLimited.java @@ -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 {} diff --git a/modules/security/src/main/scala/de/nowchess/security/RateLimitFilter.scala b/modules/security/src/main/scala/de/nowchess/security/RateLimitFilter.scala new file mode 100644 index 0000000..1c4de9b --- /dev/null +++ b/modules/security/src/main/scala/de/nowchess/security/RateLimitFilter.scala @@ -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)