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.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics