security: Server-seitige Input-Validierung & Body-Size-Limit (#67)

- Routing.kt: Content-Length-Intercept (max 1 MB, HTTP 413)
- InventoryRoutes.kt: String-Längen (name ≤255, unit ≤50, id ≤36,
  category/location ≤255, setting key ≤255), expiryDate-Regex
  YYYY-MM-DD, Array-Limits (items ≤10000, categories/locations ≤500)
- AdminRoutes.kt: username-Länge ≤255
- InputValidationTest.kt: 16 neue negative Tests, alle 532 Tests grün
This commit is contained in:
Jens Reinemann 2026-05-17 01:20:49 +02:00
parent 26b50eea36
commit d354e3b37c
4 changed files with 405 additions and 0 deletions

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.server.plugins
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.repository.MessageRepository
import de.krisenvorrat.server.repository.UserRepository
@ -15,11 +16,15 @@ import io.ktor.http.*
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.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import kotlin.time.Duration.Companion.seconds
private const val MAX_BODY_SIZE = 1 * 1024 * 1024L // 1 MB
internal fun Application.configureRouting(
inventoryRepository: InventoryRepository = InventoryRepository(),
userRepository: UserRepository = UserRepository(),
@ -33,6 +38,17 @@ internal fun Application.configureRouting(
}
routing {
intercept(ApplicationCallPipeline.Plugins) {
val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
if (contentLength != null && contentLength > MAX_BODY_SIZE) {
call.respond(
HttpStatusCode.PayloadTooLarge,
ErrorResponse(status = 413, message = "Request body too large (max 1 MB)")
)
finish()
}
}
get("/api/health") {
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
}

View file

@ -39,6 +39,10 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Username and password must not be empty"))
return@post
}
if (request.username.length > 255) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Username too long (max 255 characters)"))
return@post
}
val passwordHash = BCrypt.hashpw(request.password, BCrypt.gensalt())
val created = userRepository.create(UUID.randomUUID().toString(), request.username, passwordHash)
if (!created) {

View file

@ -12,6 +12,22 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
private val EXPIRY_DATE_REGEX = Regex("""^\d{4}-\d{2}-\d{2}$""")
private val ALLOWED_SETTING_KEYS = setOf(
"serverUrl", "syncEnabled", "theme", "language", "notificationsEnabled"
)
private fun validateItem(item: ItemDto): String? {
if (item.id.length > 36) return "Item id too long (max 36 characters)"
if (item.name.length > 255) return "Item name too long (max 255 characters)"
if (item.unit.length > 50) return "Item unit too long (max 50 characters)"
item.expiryDate?.let { date ->
if (!date.matches(EXPIRY_DATE_REGEX)) return "Invalid expiryDate format '$date' expected YYYY-MM-DD"
}
return null
}
internal fun Route.inventoryRoutes(
repository: InventoryRepository,
wsManager: WebSocketManager
@ -30,6 +46,44 @@ internal fun Route.inventoryRoutes(
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = call.receive<InventoryDto>()
// Array size limits
if (inventory.items.size > 10_000) return@put call.respond(
HttpStatusCode.UnprocessableEntity, ErrorResponse(status = 422, message = "Too many items (max 10000)")
)
if (inventory.categories.size > 500) return@put call.respond(
HttpStatusCode.UnprocessableEntity, ErrorResponse(status = 422, message = "Too many categories (max 500)")
)
if (inventory.locations.size > 500) return@put call.respond(
HttpStatusCode.UnprocessableEntity, ErrorResponse(status = 422, message = "Too many locations (max 500)")
)
// Category and location name lengths
inventory.categories.forEach { category ->
if (category.name.length > 255) return@put call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Category name too long (max 255 characters)")
)
}
inventory.locations.forEach { location ->
if (location.name.length > 255) return@put call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Location name too long (max 255 characters)")
)
}
// Item field validation
inventory.items.forEach { item ->
validateItem(item)?.let { error ->
return@put call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = error))
}
}
// Settings validation
inventory.settings.forEach { setting ->
if (setting.key.length > 255) return@put call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Setting key too long (max 255 characters)")
)
}
repository.saveInventory(inventoryId, inventory)
val saved = repository.loadInventory(inventoryId)
wsManager.notifyFullSyncRequired(userId)
@ -43,7 +97,13 @@ internal fun Route.inventoryRoutes(
val itemId = call.parameters["id"] ?: return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id")
)
if (itemId.length > 36) return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Item id too long (max 36 characters)")
)
val item = call.receive<ItemDto>()
validateItem(item)?.let { error ->
return@patch call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = error))
}
val updated = repository.patchItem(inventoryId, itemId, item)
if (!updated) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
@ -71,3 +131,4 @@ internal fun Route.inventoryRoutes(
}
}

View file

@ -0,0 +1,324 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
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 InputValidationTest {
private val json = Json { ignoreUnknownKeys = true }
private val token = createTestAccessToken()
private val adminToken = createTestAccessToken(
userId = TEST_ADMIN_ID,
username = TEST_ADMIN_USERNAME,
isAdmin = true
)
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:validation_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
private fun validItem() = ItemDto(
id = "test-item-id-001",
name = "Wasser",
categoryId = 1,
quantity = 10.0,
unit = "L",
unitPrice = 0.5,
kcalPerKg = null,
expiryDate = "2027-12-31",
locationId = 1,
notes = "",
lastUpdated = System.currentTimeMillis()
)
private fun validInventory(items: List<ItemDto> = emptyList()) = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Getränke")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = items,
settings = emptyList()
)
// ── String length validation ──────────────────────────────────────────────
@Test
fun test_putInventory_itemNameTooLong_returns400() = testApp {
val oversizedItem = validItem().copy(name = "A".repeat(256))
val body = json.encodeToString(validInventory(listOf(oversizedItem)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_itemUnitTooLong_returns400() = testApp {
val oversizedItem = validItem().copy(unit = "X".repeat(51))
val body = json.encodeToString(validInventory(listOf(oversizedItem)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_itemIdTooLong_returns400() = testApp {
val oversizedItem = validItem().copy(id = "X".repeat(37))
val body = json.encodeToString(validInventory(listOf(oversizedItem)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_categoryNameTooLong_returns400() = testApp {
val inventory = validInventory().copy(
categories = listOf(CategoryDto(id = 1, name = "A".repeat(256)))
)
val body = json.encodeToString(inventory)
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_locationNameTooLong_returns400() = testApp {
val inventory = validInventory().copy(
locations = listOf(LocationDto(id = 1, name = "A".repeat(256)))
)
val body = json.encodeToString(inventory)
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_settingKeyTooLong_returns400() = testApp {
val inventory = validInventory().copy(
settings = listOf(SettingDto(key = "A".repeat(256), value = "v"))
)
val body = json.encodeToString(inventory)
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
// ── expiryDate format validation ─────────────────────────────────────────
@Test
fun test_putInventory_invalidExpiryDate_returns400() = testApp {
val itemWithBadDate = validItem().copy(expiryDate = "not-a-date")
val body = json.encodeToString(validInventory(listOf(itemWithBadDate)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_putInventory_validExpiryDate_returns200() = testApp {
val itemWithGoodDate = validItem().copy(expiryDate = "2030-01-15")
val body = json.encodeToString(validInventory(listOf(itemWithGoodDate)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun test_putInventory_nullExpiryDate_returns200() = testApp {
val itemWithNullDate = validItem().copy(expiryDate = null)
val body = json.encodeToString(validInventory(listOf(itemWithNullDate)))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.OK, response.status)
}
// ── Array size limits ────────────────────────────────────────────────────
@Test
fun test_putInventory_tooManyItems_returnsClientError() = testApp {
// 10 001 items always exceed the 1 MB body limit, so the server may return
// 413 (body too large) before reaching the array-size check (422).
// Both status codes correctly indicate the server rejected the oversized request.
val items = (1..10_001).map { i -> validItem().copy(id = "item-$i".padEnd(36, '0').take(36)) }
val body = json.encodeToString(validInventory(items))
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertTrue(
"Expected 413 or 422, got ${response.status}",
response.status == HttpStatusCode.UnprocessableEntity || response.status == HttpStatusCode.PayloadTooLarge
)
}
@Test
fun test_putInventory_tooManyCategories_returns422() = testApp {
val inventory = validInventory().copy(
categories = (1..501).map { i -> CategoryDto(id = i, name = "Cat $i") }
)
val body = json.encodeToString(inventory)
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
}
@Test
fun test_putInventory_tooManyLocations_returns422() = testApp {
val inventory = validInventory().copy(
locations = (1..501).map { i -> LocationDto(id = i, name = "Loc $i") }
)
val body = json.encodeToString(inventory)
val response = client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
}
// ── PATCH item validation ────────────────────────────────────────────────
@Test
fun test_patchItem_invalidExpiryDate_returns400() = testApp {
// First save an item
val setupBody = json.encodeToString(validInventory(listOf(validItem())))
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(setupBody)
}
val badItem = validItem().copy(expiryDate = "31.12.2027")
val response = client.patch("/api/inventory/items/${validItem().id}") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(badItem))
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_patchItem_nameTooLong_returns400() = testApp {
val setupBody = json.encodeToString(validInventory(listOf(validItem())))
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(setupBody)
}
val badItem = validItem().copy(name = "N".repeat(256))
val response = client.patch("/api/inventory/items/${validItem().id}") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(badItem))
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
// ── Admin username length validation ─────────────────────────────────────
@Test
fun test_createUser_usernameTooLong_returns400() = testApp {
val body = """{"username":"${"U".repeat(256)}","password":"ValidP@ss1"}"""
val response = client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_createUser_validUsername255Chars_returns201() = testApp {
val body = """{"username":"${"U".repeat(255)}","password":"ValidP@ss1"}"""
val response = client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(body)
}
assertEquals(HttpStatusCode.Created, response.status)
}
}