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:
@@ -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:
|
||||
|
||||
+2
@@ -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,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