Skip to content

Commit 9e89045

Browse files
authored
Add an exception signalling invalid multipart bodies, pass as a decode result (#4861)
Resolves #4858 Invalid multiparts are "expected" exceptions, in that they can be caused by an bad request, not by a bug in the interpreter
1 parent aa5c9da commit 9e89045

File tree

9 files changed

+105
-36
lines changed

9 files changed

+105
-36
lines changed

server/akka-http-server/src/main/scala/sttp/tapir/server/akkahttp/AkkaRequestBody.scala

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import sttp.capabilities.akka.AkkaStreams
1010
import sttp.model.{Header, Part}
1111
import sttp.tapir.model.ServerRequest
1212
import sttp.tapir.server.interpreter.{RawValue, RequestBody}
13+
import sttp.tapir.server.model.InvalidMultipartBodyException
1314
import sttp.tapir.{FileRange, RawBodyType, RawPart, InputStreamRange}
1415

1516
import scala.concurrent.{ExecutionContext, Future}
@@ -59,14 +60,17 @@ private[akkahttp] class AkkaRequestBody(serverOptions: AkkaHttpServerOptions)(im
5960
case RawBodyType.InputStreamRangeBody =>
6061
Future.successful(RawValue(InputStreamRange(() => body.dataBytes.runWith(StreamConverters.asInputStream()))))
6162
case m: RawBodyType.MultipartBody =>
62-
implicitly[FromEntityUnmarshaller[Multipart.FormData]].apply(body).flatMap { fd =>
63-
fd.parts
64-
.mapConcat(part => m.partType(part.name).map((part, _)).toList)
65-
.mapAsync[RawPart](1) { case (part, codecMeta) => toRawPart(request, part, codecMeta) }
66-
.runWith[Future[scala.collection.immutable.Seq[RawPart]]](Sink.seq)
67-
.map(RawValue.fromParts)
68-
.asInstanceOf[Future[RawValue[R]]]
69-
}
63+
implicitly[FromEntityUnmarshaller[Multipart.FormData]]
64+
.apply(body)
65+
.flatMap { fd =>
66+
fd.parts
67+
.mapConcat(part => m.partType(part.name).map((part, _)).toList)
68+
.mapAsync[RawPart](1) { case (part, codecMeta) => toRawPart(request, part, codecMeta) }
69+
.runWith[Future[scala.collection.immutable.Seq[RawPart]]](Sink.seq)
70+
.map(RawValue.fromParts)
71+
.asInstanceOf[Future[RawValue[R]]]
72+
}
73+
.recoverWith { case e: ParsingException => Future.failed(InvalidMultipartBodyException(e)) }
7074
}
7175
}
7276

server/armeria-server/src/main/scala/sttp/tapir/server/armeria/ArmeriaRequestBody.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package sttp.tapir.server.armeria
22

33
import com.linecorp.armeria.common.HttpData
4-
import com.linecorp.armeria.common.multipart.{AggregatedBodyPart, Multipart}
4+
import com.linecorp.armeria.common.multipart.{AggregatedBodyPart, MimeParsingException, Multipart}
55
import com.linecorp.armeria.common.stream.{StreamMessage, StreamMessages}
66
import com.linecorp.armeria.server.ServiceRequestContext
77
import sttp.capabilities.Streams
88
import sttp.model.Part
99
import sttp.tapir.model.ServerRequest
1010
import sttp.tapir.server.interpreter.{RawValue, RequestBody}
11+
import sttp.tapir.server.model.InvalidMultipartBodyException
1112
import sttp.tapir.{FileRange, InputStreamRange, RawBodyType}
1213

1314
import java.io.ByteArrayInputStream
@@ -77,6 +78,7 @@ private[armeria] final class ArmeriaRequestBody[F[_], S <: Streams[S]](
7778
.sequence(rawParts)
7879
.map(RawValue.fromParts(_))
7980
})
81+
.recoverWith { case e: MimeParsingException => Future.failed(InvalidMultipartBodyException(e)) }
8082
.asInstanceOf[Future[RawValue[R]]]
8183
})
8284
}

server/core/src/main/scala/sttp/tapir/server/interceptor/exception/ExceptionHandler.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package sttp.tapir.server.interceptor.exception
33
import sttp.capabilities.StreamMaxLengthExceededException
44
import sttp.model.StatusCode
55
import sttp.monad.MonadError
6-
import sttp.tapir.server.model.ValuedEndpointOutput
6+
import sttp.tapir.server.model.{InvalidMultipartBodyException, ValuedEndpointOutput}
77
import sttp.tapir._
88

99
trait ExceptionHandler[F[_]] {
@@ -31,6 +31,8 @@ case class DefaultExceptionHandler[F[_]](response: (StatusCode, String) => Value
3131
monad.unit(Some(response(StatusCode.PayloadTooLarge, s"Payload limit (${maxBytes}B) exceeded")))
3232
case (_, StreamMaxLengthExceededException(maxBytes)) =>
3333
monad.unit(Some(response(StatusCode.PayloadTooLarge, s"Payload limit (${maxBytes}B) exceeded")))
34+
case (InvalidMultipartBodyException(_, _), _) =>
35+
monad.unit(Some(response(StatusCode.BadRequest, s"Invalid multipart body")))
3436
case _ =>
3537
monad.unit(Some(response(StatusCode.InternalServerError, "Internal server error")))
3638
}

server/core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import sttp.monad.syntax._
77
import sttp.tapir.internal.{Params, ParamsAsAny, RichOneOfBody}
88
import sttp.tapir.model.ServerRequest
99
import sttp.tapir.server.interceptor._
10-
import sttp.tapir.server.model.{MaxContentLength, ServerResponse, ValuedEndpointOutput}
10+
import sttp.tapir.server.model.{InvalidMultipartBodyException, MaxContentLength, ServerResponse, ValuedEndpointOutput}
1111
import sttp.tapir.server.{model, _}
1212
import sttp.tapir.{DecodeResult, EndpointIO, EndpointInput, TapirFile}
1313
import sttp.tapir.EndpointInfo
@@ -197,7 +197,9 @@ class ServerInterpreter[R, F[_], B, S](
197197
.map(_ => DecodeBasicInputsResult.Failure(bodyInput, failure): DecodeBasicInputsResult)
198198
}
199199
}
200-
.handleError { case e: StreamMaxLengthExceededException =>
200+
// if the exception is "known" - might be the result of a malformed body - treating as a decode failure
201+
// otherwise, it's a bug in the interpreter
202+
.handleError { case e @ (StreamMaxLengthExceededException(_) | InvalidMultipartBodyException(_, _)) =>
201203
(DecodeBasicInputsResult.Failure(bodyInput, DecodeResult.Error("", e)): DecodeBasicInputsResult).unit
202204
}
203205
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package sttp.tapir.server.model
2+
3+
case class InvalidMultipartBodyException(message: String, cause: Throwable) extends Exception(message, cause)
4+
5+
object InvalidMultipartBodyException {
6+
def apply(cause: Throwable): InvalidMultipartBodyException = new InvalidMultipartBodyException(cause.getMessage, cause)
7+
def apply(message: String): InvalidMultipartBodyException = InvalidMultipartBodyException(message, null)
8+
}

server/jdkhttp-server/src/main/scala/sttp/tapir/server/jdkhttp/internal/JdkHttpRequestBody.scala

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import sttp.tapir.capabilities.NoStreams
99
import sttp.tapir.model.ServerRequest
1010
import sttp.tapir.server.interpreter.{RawValue, RequestBody}
1111
import sttp.tapir.server.jdkhttp.internal.ParsedMultiPart.parseMultipartBody
12+
import sttp.tapir.server.model.InvalidMultipartBodyException
1213
import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart, TapirFile}
1314

1415
import java.io._
@@ -67,20 +68,24 @@ private[jdkhttp] class JdkHttpRequestBody(createFile: ServerRequest => TapirFile
6768
val httpExchange = jdkHttpRequest(request)
6869
val boundary = extractBoundary(httpExchange)
6970

70-
parseMultipartBody(requestBody, boundary, multipartFileThresholdBytes).flatMap(parsedPart =>
71-
parsedPart.getName.flatMap(name =>
72-
m.partType(name)
73-
.map(partType => {
74-
val bodyRawValue = toRaw(request, partType, parsedPart.getBody, maxBytes = None)
75-
Part(
76-
name,
77-
bodyRawValue.value,
78-
otherDispositionParams = parsedPart.getDispositionParams - "name",
79-
headers = parsedPart.fileItemHeaders
80-
)
81-
})
71+
try {
72+
parseMultipartBody(requestBody, boundary, multipartFileThresholdBytes).flatMap(parsedPart =>
73+
parsedPart.getName.flatMap(name =>
74+
m.partType(name)
75+
.map(partType => {
76+
val bodyRawValue = toRaw(request, partType, parsedPart.getBody, maxBytes = None)
77+
Part(
78+
name,
79+
bodyRawValue.value,
80+
otherDispositionParams = parsedPart.getDispositionParams - "name",
81+
headers = parsedPart.fileItemHeaders
82+
)
83+
})
84+
)
8285
)
83-
)
86+
} catch {
87+
case e: Exception if e.getMessage().contains("Parsing multipart failed") => throw InvalidMultipartBodyException(e)
88+
}
8489
}
8590

8691
override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream =

server/netty-server/src/main/scala/sttp/tapir/server/netty/internal/NettyRequestBody.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import sttp.monad.MonadError
1111
import sttp.monad.syntax._
1212
import sttp.tapir.model.ServerRequest
1313
import sttp.tapir.server.interpreter.{RawValue, RequestBody}
14+
import sttp.tapir.server.model.InvalidMultipartBodyException
1415
import sttp.tapir.server.netty.internal.reactivestreams.SubscriberInputStream
1516
import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart, TapirFile}
1617

@@ -85,8 +86,9 @@ private[netty] trait NettyRequestBody[F[_], S <: Streams[S]] extends RequestBody
8586
} yield RawValue(FileRange(file), Seq(FileRange(file)))
8687
case m: RawBodyType.MultipartBody =>
8788
serverRequest.underlying match {
88-
case r: StreamedHttpRequest => publisherToMultipart(r, serverRequest, m, maxBytes)
89-
case _ => monad.error(new UnsupportedOperationException("Expected a streamed request for multipart body"))
89+
case r: StreamedHttpRequest => publisherToMultipart(r, serverRequest, m, maxBytes)
90+
case r if r.getClass().getSimpleName() == "EmptyHttpRequest" => monad.error(InvalidMultipartBodyException("Empty multipart body"))
91+
case _ => monad.error(new UnsupportedOperationException("Expected a streamed request for multipart body"))
9092
}
9193
}
9294

server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoRequestBody.scala

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package sttp.tapir.server.pekkohttp
22

3-
import org.apache.pekko.http.scaladsl.model.{EntityStreamSizeException, HttpEntity, Multipart, RequestEntity}
3+
import org.apache.pekko.http.scaladsl.model.{EntityStreamSizeException, HttpEntity, Multipart, ParsingException, RequestEntity}
44
import org.apache.pekko.http.scaladsl.server.RequestContext
55
import org.apache.pekko.http.scaladsl.unmarshalling.FromEntityUnmarshaller
66
import org.apache.pekko.stream.scaladsl.{FileIO, Sink, _}
@@ -10,6 +10,7 @@ import sttp.capabilities.pekko.PekkoStreams
1010
import sttp.model.{Header, Part}
1111
import sttp.tapir.model.ServerRequest
1212
import sttp.tapir.server.interpreter.{RawValue, RequestBody}
13+
import sttp.tapir.server.model.InvalidMultipartBodyException
1314
import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, RawPart}
1415

1516
import scala.concurrent.{ExecutionContext, Future}
@@ -59,14 +60,17 @@ private[pekkohttp] class PekkoRequestBody(serverOptions: PekkoHttpServerOptions)
5960
case RawBodyType.InputStreamRangeBody =>
6061
Future.successful(RawValue(InputStreamRange(() => body.dataBytes.runWith(StreamConverters.asInputStream()))))
6162
case m: RawBodyType.MultipartBody =>
62-
implicitly[FromEntityUnmarshaller[Multipart.FormData]].apply(body).flatMap { fd =>
63-
fd.parts
64-
.mapConcat(part => m.partType(part.name).map((part, _)).toList)
65-
.mapAsync[RawPart](1) { case (part, codecMeta) => toRawPart(request, part, codecMeta) }
66-
.runWith[Future[scala.collection.immutable.Seq[RawPart]]](Sink.seq)
67-
.map(RawValue.fromParts)
68-
.asInstanceOf[Future[RawValue[R]]]
69-
}
63+
implicitly[FromEntityUnmarshaller[Multipart.FormData]]
64+
.apply(body)
65+
.flatMap { fd =>
66+
fd.parts
67+
.mapConcat(part => m.partType(part.name).map((part, _)).toList)
68+
.mapAsync[RawPart](1) { case (part, codecMeta) => toRawPart(request, part, codecMeta) }
69+
.runWith[Future[scala.collection.immutable.Seq[RawPart]]](Sink.seq)
70+
.map(RawValue.fromParts)
71+
.asInstanceOf[Future[RawValue[R]]]
72+
}
73+
.recoverWith { case e: ParsingException => Future.failed(InvalidMultipartBodyException(e)) }
7074
}
7175
}
7276

server/tests/src/main/scala/sttp/tapir/server/tests/ServerMultipartTests.scala

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,46 @@ class ServerMultipartTests[F[_], OPTIONS, ROUTE](
187187
r.code shouldBe StatusCode.Ok
188188
r.body should be("firstPart:BODYONE\r\n--AA\n__\nsecondPart:BODYTWO")
189189
}
190+
},
191+
testServer(in_raw_multipart_out_string, "empty multipart body")((parts: Seq[Part[Array[Byte]]]) =>
192+
pureResult(parts.length.toString.asRight[Unit])
193+
) { (backend, baseUri) =>
194+
basicStringRequest
195+
.post(uri"$baseUri/api/echo/multipart")
196+
.header("Content-Type", "multipart/form-data; boundary=AAB")
197+
.body("")
198+
.send(backend)
199+
.map { r =>
200+
// no parts should be parsed, or a bad request should be returned
201+
r.code match {
202+
case StatusCode.BadRequest => succeed
203+
case StatusCode.Ok => r.body should be("0")
204+
case _ =>
205+
fail("Expected BadRequest, but got " + r.code)
206+
}
207+
}
208+
},
209+
testServer(in_raw_multipart_out_string, "invalid multipart body")((parts: Seq[Part[Array[Byte]]]) =>
210+
pureResult(parts.length.toString.asRight[Unit])
211+
) { (backend, baseUri) =>
212+
val testBody = "--ABC\r\n" + // different boundary
213+
"Content-Disposition: form-data; name=\"firstPart\"\r\n" +
214+
"Content-Type: text/plain\r\n" +
215+
"-ABC\r\n" // invalid boundary
216+
basicStringRequest
217+
.post(uri"$baseUri/api/echo/multipart")
218+
.header("Content-Type", "multipart/form-data; boundary=AAB")
219+
.body(testBody)
220+
.send(backend)
221+
.map { r =>
222+
// no parts should be parsed, or a bad request should be returned
223+
r.code match {
224+
case StatusCode.BadRequest => succeed
225+
case StatusCode.Ok => r.body should be("0")
226+
case _ =>
227+
fail("Expected BadRequest, but got " + r.code)
228+
}
229+
}
190230
}
191231
)
192232
}

0 commit comments

Comments
 (0)