Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a20bee3b93 | |||
| 1ae455eb99 |
@@ -30,8 +30,15 @@ nowchess:
|
|||||||
prefix: nowchess
|
prefix: nowchess
|
||||||
internal:
|
internal:
|
||||||
secret: 123abc
|
secret: 123abc
|
||||||
|
rate-limit:
|
||||||
|
enabled: true
|
||||||
|
requests-per-window: 60
|
||||||
|
window-seconds: 60
|
||||||
|
|
||||||
"%test":
|
"%test":
|
||||||
|
nowchess:
|
||||||
|
rate-limit:
|
||||||
|
enabled: false
|
||||||
quarkus:
|
quarkus:
|
||||||
datasource:
|
datasource:
|
||||||
db-kind: h2
|
db-kind: h2
|
||||||
@@ -89,6 +96,8 @@ nowchess:
|
|||||||
prefix: ${REDIS_PREFIX:nowchess}
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
internal:
|
internal:
|
||||||
secret: ${INTERNAL_SECRET}
|
secret: ${INTERNAL_SECRET}
|
||||||
|
rate-limit:
|
||||||
|
gatling-secret: ${GATLING_SECRET}
|
||||||
mp:
|
mp:
|
||||||
jwt:
|
jwt:
|
||||||
verify:
|
verify:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
|||||||
import de.nowchess.account.dto.*
|
import de.nowchess.account.dto.*
|
||||||
import de.nowchess.account.error.AccountError
|
import de.nowchess.account.error.AccountError
|
||||||
import de.nowchess.account.service.AccountService
|
import de.nowchess.account.service.AccountService
|
||||||
|
import de.nowchess.security.RateLimited
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
@@ -16,6 +17,7 @@ import java.util.UUID
|
|||||||
|
|
||||||
@Path("/api/account")
|
@Path("/api/account")
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
|
@RateLimited
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
class AccountResource:
|
class AccountResource:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.nowchess.account.resource
|
|||||||
import de.nowchess.account.dto.*
|
import de.nowchess.account.dto.*
|
||||||
import de.nowchess.account.error.ChallengeError
|
import de.nowchess.account.error.ChallengeError
|
||||||
import de.nowchess.account.service.ChallengeService
|
import de.nowchess.account.service.ChallengeService
|
||||||
|
import de.nowchess.security.RateLimited
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
@@ -16,6 +17,7 @@ import java.util.UUID
|
|||||||
@Path("/api/challenge")
|
@Path("/api/challenge")
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
|
@RateLimited
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
class ChallengeResource:
|
class ChallengeResource:
|
||||||
|
|||||||
+2
@@ -3,6 +3,7 @@ package de.nowchess.account.resource
|
|||||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
||||||
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
||||||
import de.nowchess.account.service.{AccountService, EventPublisher}
|
import de.nowchess.account.service.{AccountService, EventPublisher}
|
||||||
|
import de.nowchess.security.RateLimited
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
@@ -19,6 +20,7 @@ import java.util.concurrent.ThreadLocalRandom
|
|||||||
@Path("/api/challenge/official")
|
@Path("/api/challenge/official")
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
|
@RateLimited
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
class OfficialChallengeResource:
|
class OfficialChallengeResource:
|
||||||
|
|||||||
@@ -34,3 +34,17 @@ nowchess:
|
|||||||
secret: test-secret
|
secret: test-secret
|
||||||
auth:
|
auth:
|
||||||
enabled: false
|
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)
|
||||||
Reference in New Issue
Block a user