From d354e3b37c4b0c84867d60edd9d72ae3250c751c Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 01:20:49 +0200 Subject: [PATCH] security: Server-seitige Input-Validierung & Body-Size-Limit (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../de/krisenvorrat/server/plugins/Routing.kt | 16 + .../krisenvorrat/server/routes/AdminRoutes.kt | 4 + .../server/routes/InventoryRoutes.kt | 61 ++++ .../server/InputValidationTest.kt | 324 ++++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/InputValidationTest.kt 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 6251ced..bbb2e30 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt @@ -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) } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt index 295a754..734ceec 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt @@ -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) { diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt index 92c4b26..9512e06 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt @@ -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() + + // 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() + 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( } } + diff --git a/server/src/test/kotlin/de/krisenvorrat/server/InputValidationTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/InputValidationTest.kt new file mode 100644 index 0000000..7b4a108 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/InputValidationTest.kt @@ -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 = 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) + } +}