From 7c17f8ea2fd96b07a6d57f27c66463b4edf60617 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 03:31:57 +0200 Subject: [PATCH] feat(server): Rate-Limiting auf alle API-Endpoints Ktor RateLimit-Plugin mit abgestuften Limits pro Endpoint-Gruppe: - Auth (/api/auth/*): 10 req/min per IP (Brute-Force-Schutz) - Messages (/api/messages/*): 30 req/min per IP (Spam-Schutz) - Inventory (/api/inventory/*): 60 req/min per IP (DoS-Schutz) - Admin (/api/admin/*): 20 req/min per IP Neue Dateien: - RateLimiting.kt: Plugin-Konfiguration mit 4 benannten Limitern - RateLimitingTest.kt: 5 Tests (Limit-Ueberschreitung, Within-Limit, Health-Endpoint ohne Limit, Retry-After-Header) Geaenderte Dateien: - Routing.kt: rateLimit()-Wrapper um Route-Gruppen - Application.kt: configureRateLimiting() in Plugin-Pipeline - libs.versions.toml + build.gradle.kts: ktor-server-rate-limit Dep Closes #75 --- gradle/libs.versions.toml | 1 + server/build.gradle.kts | 1 + .../de/krisenvorrat/server/Application.kt | 2 + .../server/plugins/RateLimiting.kt | 47 ++++++ .../de/krisenvorrat/server/plugins/Routing.kt | 19 ++- .../krisenvorrat/server/RateLimitingTest.kt | 134 ++++++++++++++++++ 6 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/plugins/RateLimiting.kt create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/RateLimitingTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77a127a..064ce6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" } ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" } ktor-server-auth-jwt = { group = "io.ktor", name = "ktor-server-auth-jwt", version.ref = "ktor" } +ktor-server-rate-limit = { group = "io.ktor", name = "ktor-server-rate-limit", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 257f56b..ba47dc5 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.ktor.server.auth) implementation(libs.ktor.server.auth.jwt) implementation(libs.ktor.server.websockets) + implementation(libs.ktor.server.rate.limit) implementation(libs.ktor.server.call.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.jbcrypt) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt index 018cdc6..5c8645a 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt @@ -3,6 +3,7 @@ package de.krisenvorrat.server import de.krisenvorrat.server.db.DatabaseFactory import de.krisenvorrat.server.plugins.configureAuthentication import de.krisenvorrat.server.plugins.configureCallLogging +import de.krisenvorrat.server.plugins.configureRateLimiting import de.krisenvorrat.server.plugins.configureRouting import de.krisenvorrat.server.plugins.configureSerialization import de.krisenvorrat.server.plugins.configureStatusPages @@ -28,6 +29,7 @@ internal fun Application.configurePlugins() { configureSerialization() configureStatusPages() configureCallLogging() + configureRateLimiting() configureAuthentication() configureRouting() } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/RateLimiting.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/RateLimiting.kt new file mode 100644 index 0000000..43d55be --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/RateLimiting.kt @@ -0,0 +1,47 @@ +package de.krisenvorrat.server.plugins + +import io.ktor.server.application.* +import io.ktor.server.plugins.ratelimit.* +import io.ktor.server.request.* +import kotlin.time.Duration.Companion.minutes + +internal val RATE_LIMIT_AUTH = RateLimitName("auth") +internal val RATE_LIMIT_MESSAGES = RateLimitName("messages") +internal val RATE_LIMIT_INVENTORY = RateLimitName("inventory") +internal val RATE_LIMIT_ADMIN = RateLimitName("admin") + +internal fun Application.configureRateLimiting() { + install(RateLimit) { + // Auth endpoints: 10 requests per minute per IP (brute-force protection) + register(RATE_LIMIT_AUTH) { + rateLimiter(limit = 10, refillPeriod = 1.minutes) + requestKey { call -> + call.request.local.remoteAddress + } + } + + // Message endpoints: 30 requests per minute per user + register(RATE_LIMIT_MESSAGES) { + rateLimiter(limit = 30, refillPeriod = 1.minutes) + requestKey { call -> + call.request.local.remoteAddress + } + } + + // Inventory endpoints: 60 requests per minute per user + register(RATE_LIMIT_INVENTORY) { + rateLimiter(limit = 60, refillPeriod = 1.minutes) + requestKey { call -> + call.request.local.remoteAddress + } + } + + // Admin endpoints: 20 requests per minute per user + register(RATE_LIMIT_ADMIN) { + rateLimiter(limit = 20, refillPeriod = 1.minutes) + requestKey { call -> + call.request.local.remoteAddress + } + } + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt index bbb2e30..bbaf68c 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt @@ -17,6 +17,7 @@ import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.http.content.* import io.ktor.server.plugins.* +import io.ktor.server.plugins.ratelimit.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -53,14 +54,22 @@ internal fun Application.configureRouting( call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK) } - // Public auth endpoints - authRoutes(userRepository, jwtService) + // Public auth endpoints (10 req/min per IP) + rateLimit(RATE_LIMIT_AUTH) { + authRoutes(userRepository, jwtService) + } // Protected endpoints authenticate("auth-jwt") { - inventoryRoutes(inventoryRepository, wsManager) - adminRoutes(userRepository, inventoryRepository) - messageRoutes(messageRepository, userRepository, wsManager) + rateLimit(RATE_LIMIT_INVENTORY) { + inventoryRoutes(inventoryRepository, wsManager) + } + rateLimit(RATE_LIMIT_ADMIN) { + adminRoutes(userRepository, inventoryRepository) + } + rateLimit(RATE_LIMIT_MESSAGES) { + messageRoutes(messageRepository, userRepository, wsManager) + } userRoutes(userRepository) } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/RateLimitingTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/RateLimitingTest.kt new file mode 100644 index 0000000..96fdad5 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/RateLimitingTest.kt @@ -0,0 +1,134 @@ +package de.krisenvorrat.server + +import de.krisenvorrat.server.db.DatabaseFactory +import de.krisenvorrat.server.model.ErrorResponse +import de.krisenvorrat.server.model.LoginRequest +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RateLimitingTest { + + private val json = Json { ignoreUnknownKeys = true } + + private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + environment { + config = MapApplicationConfig(*testMapConfig().toTypedArray()) + } + application { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:ratelimit_test_${System.nanoTime()};DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + adminPassword = "test-admin-pw" + ) + configurePlugins() + } + block() + } + + @Test + fun test_authLogin_exceedsRateLimit_returns429() = testApp { + // Given: auth rate limit is 10 req/min + val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password")) + + // When: send 11 requests (exceeding the 10 req/min limit) + var lastStatus: HttpStatusCode = HttpStatusCode.OK + for (i in 1..11) { + val response = client.post("/api/auth/login") { + contentType(ContentType.Application.Json) + setBody(loginBody) + } + lastStatus = response.status + } + + // Then: the 11th request should be rate-limited + assertEquals(HttpStatusCode.TooManyRequests, lastStatus) + } + + @Test + fun test_authLogin_withinRateLimit_notBlocked() = testApp { + // Given: auth rate limit is 10 req/min + val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password")) + + // When: send exactly 10 requests (within limit) + val statuses = (1..10).map { + client.post("/api/auth/login") { + contentType(ContentType.Application.Json) + setBody(loginBody) + }.status + } + + // Then: none should be 429 + assertTrue( + "Expected no 429 responses within limit, got: $statuses", + statuses.none { it == HttpStatusCode.TooManyRequests } + ) + } + + @Test + fun test_inventoryGet_exceedsRateLimit_returns429() = testApp { + // Given: inventory rate limit is 60 req/min + val token = createTestAccessToken() + + // When: send 61 requests + var lastStatus: HttpStatusCode = HttpStatusCode.OK + for (i in 1..61) { + val response = client.get("/api/inventory") { + bearerAuth(token) + } + lastStatus = response.status + } + + // Then: the 61st request should be rate-limited + assertEquals(HttpStatusCode.TooManyRequests, lastStatus) + } + + @Test + fun test_healthEndpoint_noRateLimit_alwaysOk() = testApp { + // Given: health endpoint has no rate limit + + // When: send many requests + val statuses = (1..100).map { + client.get("/api/health").status + } + + // Then: all should be 200 + assertTrue( + "Health endpoint should never be rate-limited", + statuses.all { it == HttpStatusCode.OK } + ) + } + + @Test + fun test_rateLimitResponse_containsRetryAfterHeader() = testApp { + // Given: exceed auth rate limit + val loginBody = json.encodeToString(LoginRequest("admin", "wrong-password")) + repeat(10) { + client.post("/api/auth/login") { + contentType(ContentType.Application.Json) + setBody(loginBody) + } + } + + // When: send one more request + val response = client.post("/api/auth/login") { + contentType(ContentType.Application.Json) + setBody(loginBody) + } + + // Then: should have Retry-After header + assertEquals(HttpStatusCode.TooManyRequests, response.status) + val retryAfter = response.headers["Retry-After"] + assertTrue( + "Expected Retry-After header in 429 response", + retryAfter != null + ) + } +}