Compare commits

...

2 Commits

Author SHA1 Message Date
Janis a20bee3b93 fix(security): guard against null UriInfo in rate limit log
Build & Test (NowChessSystems) TeamCity build finished
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:33:40 +02:00
Janis 1ae455eb99 feat(security): add per-IP rate limiting to account API endpoints
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>
2026-06-02 15:03:16 +02:00
8 changed files with 200 additions and 0 deletions
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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
@@ -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())
@@ -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,70 @@
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
val path = Option(ctx.getUriInfo).map(_.getPath).getOrElse("-")
log.warnf("Rate limit exceeded for IP %s on %s %s", ip, ctx.getMethod, path)
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)