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:
parent
26b50eea36
commit
d354e3b37c
4 changed files with 405 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue