Skip to content

Commit

Permalink
KTOR-7470 receiveMultipart throw UnsupportedMediaTypeException (#4339)
Browse files Browse the repository at this point in the history
  • Loading branch information
stokado authored and osipxd committed Dec 12, 2024
1 parent 74aa326 commit 9b0acca
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 14 deletions.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
#

# sytleguide
# styleguide
kotlin.code.style=official

# config
Expand Down
4 changes: 4 additions & 0 deletions ktor-http/ktor-http-cio/api/ktor-http-cio.api
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,7 @@ public final class io/ktor/http/cio/internals/MutableRange {
public fun toString ()Ljava/lang/String;
}

public final class io/ktor/http/cio/internals/UnsupportedMediaTypeExceptionCIO : java/io/IOException {
public fun <init> (Ljava/lang/String;)V
}

4 changes: 4 additions & 0 deletions ktor-http/ktor-http-cio/api/ktor-http-cio.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ final class io.ktor.http.cio.internals/MutableRange { // io.ktor.http.cio.intern
final fun toString(): kotlin/String // io.ktor.http.cio.internals/MutableRange.toString|toString(){}[0]
}

final class io.ktor.http.cio.internals/UnsupportedMediaTypeExceptionCIO : kotlinx.io/IOException { // io.ktor.http.cio.internals/UnsupportedMediaTypeExceptionCIO|null[0]
constructor <init>(kotlin/String) // io.ktor.http.cio.internals/UnsupportedMediaTypeExceptionCIO.<init>|<init>(kotlin.String){}[0]
}

final class io.ktor.http.cio/CIOHeaders : io.ktor.http/Headers { // io.ktor.http.cio/CIOHeaders|null[0]
constructor <init>(io.ktor.http.cio/HttpHeadersMap) // io.ktor.http.cio/CIOHeaders.<init>|<init>(io.ktor.http.cio.HttpHeadersMap){}[0]

Expand Down
10 changes: 8 additions & 2 deletions ktor-http/ktor-http-cio/common/src/io/ktor/http/cio/Multipart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,15 @@ private suspend fun ByteReadChannel.skipIfFoundReadCount(prefix: ByteString): Lo
/**
* Starts a multipart parser coroutine producing multipart events
*/
@OptIn(InternalAPI::class)
public fun CoroutineScope.parseMultipart(
input: ByteReadChannel,
headers: HttpHeadersMap,
maxPartSize: Long = Long.MAX_VALUE
): ReceiveChannel<MultipartEvent> {
val contentType = headers["Content-Type"] ?: throw IOException("Failed to parse multipart: no Content-Type header")
val contentType = headers["Content-Type"] ?: throw UnsupportedMediaTypeExceptionCIO(
"Failed to parse multipart: no Content-Type header"
)
val contentLength = headers["Content-Length"]?.parseDecLong()

return parseMultipart(input, contentType, contentLength, maxPartSize)
Expand All @@ -150,14 +153,17 @@ public fun CoroutineScope.parseMultipart(
/**
* Starts a multipart parser coroutine producing multipart events
*/
@OptIn(InternalAPI::class)
public fun CoroutineScope.parseMultipart(
input: ByteReadChannel,
contentType: CharSequence,
contentLength: Long?,
maxPartSize: Long = Long.MAX_VALUE,
): ReceiveChannel<MultipartEvent> {
if (!contentType.startsWith("multipart/", ignoreCase = true)) {
throw IOException("Failed to parse multipart: Content-Type should be multipart/* but it is $contentType")
throw UnsupportedMediaTypeExceptionCIO(
"Failed to parse multipart: Content-Type should be multipart/* but it is $contentType"
)
}
val boundaryByteBuffer = parseBoundaryInternal(contentType)
val boundaryBytes = ByteString(boundaryByteBuffer)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.http.cio.internals

import io.ktor.utils.io.*
import kotlinx.io.IOException

@InternalAPI
public class UnsupportedMediaTypeExceptionCIO(message: String) : IOException(message)
2 changes: 1 addition & 1 deletion ktor-server/ktor-server-core/api/ktor-server-core.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ final class io.ktor.server.plugins/PayloadTooLargeException : io.ktor.server.plu
}

final class io.ktor.server.plugins/UnsupportedMediaTypeException : io.ktor.server.plugins/ContentTransformationException, kotlinx.coroutines/CopyableThrowable<io.ktor.server.plugins/UnsupportedMediaTypeException> { // io.ktor.server.plugins/UnsupportedMediaTypeException|null[0]
constructor <init>(io.ktor.http/ContentType) // io.ktor.server.plugins/UnsupportedMediaTypeException.<init>|<init>(io.ktor.http.ContentType){}[0]
constructor <init>(io.ktor.http/ContentType?) // io.ktor.server.plugins/UnsupportedMediaTypeException.<init>|<init>(io.ktor.http.ContentType?){}[0]

final fun createCopy(): io.ktor.server.plugins/UnsupportedMediaTypeException // io.ktor.server.plugins/UnsupportedMediaTypeException.createCopy|createCopy(){}[0]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ public class CannotTransformContentToTypeException(
*/
@OptIn(ExperimentalCoroutinesApi::class)
public class UnsupportedMediaTypeException(
private val contentType: ContentType
) : ContentTransformationException("Content type $contentType is not supported"),
private val contentType: ContentType?
) : ContentTransformationException(
contentType?.let { "Content type $it is not supported" }
?: "Content-Type header is required"
),
CopyableThrowable<UnsupportedMediaTypeException> {

override fun createCopy(): UnsupportedMediaTypeException = UnsupportedMediaTypeException(contentType).also {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ package io.ktor.server.engine

import io.ktor.http.*
import io.ktor.http.cio.*
import io.ktor.http.cio.internals.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.plugins.UnsupportedMediaTypeException
import io.ktor.server.request.*
import io.ktor.util.pipeline.*
import io.ktor.utils.io.*
Expand All @@ -33,16 +35,21 @@ internal actual suspend fun PipelineContext<Any, PipelineCall>.defaultPlatformTr
@OptIn(InternalAPI::class)
internal actual fun PipelineContext<*, PipelineCall>.multiPartData(rc: ByteReadChannel): MultiPartData {
val contentType = call.request.header(HttpHeaders.ContentType)
?: throw IllegalStateException("Content-Type header is required for multipart processing")
?: throw UnsupportedMediaTypeException(null)

val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLong()
return CIOMultipartDataBase(
coroutineContext + Dispatchers.Unconfined,
rc,
contentType,
contentLength,
call.formFieldLimit
)

try {
return CIOMultipartDataBase(
coroutineContext + Dispatchers.Unconfined,
rc,
contentType,
contentLength,
call.formFieldLimit
)
} catch (_: UnsupportedMediaTypeExceptionCIO) {
throw UnsupportedMediaTypeException(ContentType.parse(contentType))
}
}

internal actual fun Source.readTextWithCustomCharset(charset: Charset): String =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.tests.server.engine

import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import io.ktor.util.pipeline.*
import io.ktor.utils.io.*
import io.mockk.*
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import kotlin.test.*

class MultiPartDataTest {
private val mockContext = mockk<PipelineContext<*, PipelineCall>>(relaxed = true)
private val mockRequest = mockk<PipelineRequest>(relaxed = true)
private val testScope = TestScope()

@Test
fun givenRequest_whenNoContentTypeHeaderPresent_thenUnsupportedMediaTypeException() {
// Given
every { mockContext.call.request } returns mockRequest
every { mockRequest.header(HttpHeaders.ContentType) } returns null

// When & Then
assertFailsWith<UnsupportedMediaTypeException> {
runBlocking { mockContext.multiPartData(ByteReadChannel("sample data")) }
}
}

@Test
fun givenWrongContentType_whenProcessMultiPart_thenUnsupportedMediaTypeException() {
// Given
val rc = ByteReadChannel("sample data")
val contentType = "test/plain; boundary=test"
val contentLength = "123"
every { mockContext.call.request } returns mockRequest
every { mockContext.call.attributes.getOrNull<Long>(any()) } returns 0L
every { mockRequest.header(HttpHeaders.ContentType) } returns contentType
every { mockRequest.header(HttpHeaders.ContentLength) } returns contentLength

// When & Then
testScope.runTest {
assertFailsWith<UnsupportedMediaTypeException> {
mockContext.multiPartData(rc)
}
}
}

@Test
fun testUnsupportedMediaTypeStatusCode() = testApplication {
routing {
post {
call.receiveMultipart()
call.respond(HttpStatusCode.OK)
}
}

client.post {
accept(ContentType.Text.Plain)
}.apply {
assertEquals(HttpStatusCode.UnsupportedMediaType, status)
}

client.post {}.apply {
assertEquals(HttpStatusCode.UnsupportedMediaType, status)
}
}
}

0 comments on commit 9b0acca

Please sign in to comment.