KtorMiddleTechnical
Какие security-риски возникают в Ktor при CORS, JWT/session auth и обработке multipart-загрузок?
Основные риски: CORS с anyHost() открывает API любому домену; JWT без проверки iss/aud позволяет использовать токены с других сервисов; multipart без ограничения размера и типа файла уязвим к DoS и path traversal.
Security-риски в Ktor: CORS, JWT/sessions и multipart
CORS: неправильная конфигурация
CORS-плагин Ktor по умолчанию блокирует все cross-origin запросы. Проблемы начинаются при излишне мягкой настройке:
// ОПАСНО: разрешает любой origin
install(CORS) {
anyHost() // эквивалент Access-Control-Allow-Origin: *
allowCredentials = true // НЕЛЬЗЯ вместе с anyHost()
allowHeader(HttpHeaders.Authorization) // лишнее при cookie-auth
}
// ПРАВИЛЬНО: явный список origins
install(CORS) {
allowHost("app.example.com", schemes = listOf("https"))
allowHost("admin.example.com", schemes = listOf("https"))
allowCredentials = true
allowHeader(HttpHeaders.ContentType)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
maxAgeInSeconds = 3600
}
Важно: allowCredentials = true и anyHost() несовместимы по спецификации — браузер откажет в таком запросе, но это не защита, т.к. атака может идти не из браузера.
JWT: неполная верификация
// ОПАСНО: не проверяет iss и aud
install(Authentication) {
jwt("auth-jwt") {
verifier(JWT.require(Algorithm.HMAC256(secret)).build())
validate { it.payload.getClaim("sub").asString()?.let { JWTPrincipal(it.payload) } }
}
}
// ПРАВИЛЬНО: строгая верификация
install(Authentication) {
jwt("auth-jwt") {
realm = "My API"
verifier(
JWT.require(Algorithm.HMAC256(secret))
.withIssuer("https://auth.example.com") // проверяем источник
.withAudience("my-api") // проверяем получателя
.acceptLeeway(30) // 30 сек погрешность часов
.build()
)
validate { credential ->
val sub = credential.payload.getClaim("sub").asString()
val exp = credential.payload.expiresAt
if (sub != null && exp != null && exp.after(Date())) {
JWTPrincipal(credential.payload)
} else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Invalid token"))
}
}
}
Session auth: защита кук
install(Sessions) {
cookie<UserSession>("session") {
cookie.httpOnly = true // недоступна из JavaScript
cookie.secure = true // только HTTPS
cookie.sameSite = SameSite.Strict // защита от CSRF
cookie.maxAgeInSeconds = 3600
// Шифрование + подпись куки
transform(SessionTransportTransformerEncrypt(
hex("00112233445566778899aabbccddeeff"), // encryptionKey 16/24/32 bytes
hex("02030405060708090a0b0c0d0e0f1011") // signKey 32 bytes
))
}
}
Multipart: DoS и path traversal
// ОПАСНО: нет ограничений на размер и тип
post("/upload") {
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
val file = File("uploads/${part.originalFileName}")
part.streamProvider().use { it.copyTo(file.outputStream()) }
}
part.dispose()
}
}
// ПРАВИЛЬНО: ограничения размера, типа и имени файла
post("/upload") {
// Ограничение размера всего запроса (16 MB)
val multipart = call.receiveMultipart(formFieldLimit = 16 * 1024 * 1024L)
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
// Проверяем Content-Type
val contentType = part.contentType?.toString() ?: ""
require(contentType in listOf("image/jpeg", "image/png", "application/pdf")) {
"Unsupported file type"
}
// Защита от path traversal: только имя файла без пути
val safeName = File(part.originalFileName ?: "file").name
.filter { it.isLetterOrDigit() || it == '.' || it == '_' || it == '-' }
require(safeName.isNotBlank()) { "Invalid file name" }
// Сохраняем в UUID-именованный файл
val dest = File("uploads/${UUID.randomUUID()}_${safeName}")
part.streamProvider().use { input ->
dest.outputStream().use { output ->
input.copyTo(output, bufferSize = 8192)
}
}
}
part.dispose()
}
call.respond(HttpStatusCode.Created)
}
Подводные камни
anyHost()+allowCredentials = true: браузер отклонит ответ, но это не защита от non-browser клиентов.- JWT
expпроверяется библиотекой java-jwt, но только если вы вызываете.build()без явногоacceptExpiresAt— без этого просроченные токены принимаются. - Имя файла из multipart контролируется клиентом —
../../../etc/passwdвoriginalFileName— всегда санировать. - Session cookie без
SameSite=Strictуязвима к CSRF-атакам даже сhttpOnly=true. - ContentType multipart/form-data не ограничивает размер отдельного поля — используйте
formFieldLimitвreceiveMultipart(). - Загруженные файлы нужно проверять magic bytes, а не только Content-Type — клиент может отправить исполняемый файл с
image/jpeg. - CORS не защищает от запросов с
curlили мобильных приложений — это защита только для браузеров; для API нужна полноценная авторизация. - Refresh token без rotation позволяет переиспользовать украденный токен бесконечно — инвалидируйте старый при выдаче нового.
Common mistakes
- Отвечать определением без production-сценария.
- Не называть runtime boundary, security boundary или failure mode.
- Игнорировать версию API, observability и тестовую проверку.
What the interviewer is testing
- Объясняет механизм своими словами и без выдуманных API.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.