diff --git a/core/play-integration-test/src/it/scala/play/it/http/parsing/MultipartFormDataParserSpec.scala b/core/play-integration-test/src/it/scala/play/it/http/parsing/MultipartFormDataParserSpec.scala index 8510f47d5eb..4d7c5af468d 100644 --- a/core/play-integration-test/src/it/scala/play/it/http/parsing/MultipartFormDataParserSpec.scala +++ b/core/play-integration-test/src/it/scala/play/it/http/parsing/MultipartFormDataParserSpec.scala @@ -49,6 +49,14 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { | |text field with unquoted name and colon |--aabbccddee + |Content-Disposition: form-data; name="empty_text" + | + | + |--aabbccddee + |Content-Disposition: form-data; name="" + | + |empty name should work + |--aabbccddee |Content-Disposition: form-data; name="arr[]" | |array value 0 @@ -111,6 +119,11 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { |the fifth file (with empty filename) | |--aabbccddee + |Content-Disposition: form-data; name="empty_file_empty_filename"; filename="" + |Content-Type: application/octet-stream + | + | + |--aabbccddee |Content-Disposition: form-data; name="empty_file_bottom"; filename="empty_file_not_followed_by_any_other_part.txt" |Content-Type: text/plain | @@ -123,11 +136,13 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { def checkResult(result: Either[Result, MultipartFormData[TemporaryFile]]) = { result must beRight.like { case parts => - parts.dataParts must haveLength(7) + parts.dataParts must haveLength(9) parts.dataParts.get("text1") must beSome(Seq("the first text field")) parts.dataParts.get("text2:colon") must beSome(Seq("the second text field")) parts.dataParts.get("noQuotesText1") must beSome(Seq("text field with unquoted name")) parts.dataParts.get("noQuotesText1:colon") must beSome(Seq("text field with unquoted name and colon")) + parts.dataParts.get("empty_text") must beSome(Seq("")) + parts.dataParts.get("") must beSome(Seq("empty name should work")) parts.dataParts.get("arr[]").get must contain(("array value 0")) parts.dataParts.get("arr[]").get must contain(("array value 1")) parts.dataParts.get("orderedarr[0]") must beSome(Seq("ordered array value 0")) @@ -157,7 +172,7 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { parts.file("file_with_newline_only") must beSome.like { case filePart => PlayIO.readFileAsString(filePart.ref) must_== "\r\n" } - parts.badParts must haveLength(4) + parts.badParts must haveLength(5) parts.badParts must contain( (BadPart( Map( @@ -182,6 +197,14 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { ) )) ) + parts.badParts must contain( + (BadPart( + Map( + "content-disposition" -> """form-data; name="empty_file_empty_filename"; filename=""""", + "content-type" -> "application/octet-stream" + ) + )) + ) parts.badParts must contain( (BadPart( Map( @@ -193,6 +216,84 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { } } + def checkResultEmptyFileAllowed(result: Either[Result, MultipartFormData[TemporaryFile]]) = { + result must beRight.like { + case parts => + parts.dataParts must haveLength(9) + parts.dataParts.get("text1") must beSome(Seq("the first text field")) + parts.dataParts.get("text2:colon") must beSome(Seq("the second text field")) + parts.dataParts.get("noQuotesText1") must beSome(Seq("text field with unquoted name")) + parts.dataParts.get("noQuotesText1:colon") must beSome(Seq("text field with unquoted name and colon")) + parts.dataParts.get("empty_text") must beSome(Seq("")) + parts.dataParts.get("") must beSome(Seq("empty name should work")) + parts.dataParts.get("arr[]").get must contain(("array value 0")) + parts.dataParts.get("arr[]").get must contain(("array value 1")) + parts.dataParts.get("orderedarr[0]") must beSome(Seq("ordered array value 0")) + parts.dataParts.get("orderedarr[1]") must beSome(Seq("ordered array value 1")) + parts.files must haveLength(10) + parts.file("file1") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "the first file\r\n" + filePart.fileSize must_== 16 + } + } + parts.file("file2") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "the second file\r\n" + filePart.fileSize must_== 17 + } + } + parts.file("file3") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "the third file (with 'Content-Disposition: file' instead of 'form-data' as used in webhook callbacks of some scanners, see issue #8527)\r\n" + filePart.fileSize must_== 137 + } + } + parts.file("file_with_space_only") must beSome.like { + case filePart => PlayIO.readFileAsString(filePart.ref) must_== " " + } + parts.file("file_with_newline_only") must beSome.like { + case filePart => PlayIO.readFileAsString(filePart.ref) must_== "\r\n" + } + parts.file("empty_file_middle") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "" + filePart.fileSize must_== 0 + filePart.filename must_== "empty_file_followed_by_other_part.txt" + } + } + parts.file("file4") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "the fourth file (with empty filename)\r\n" + filePart.fileSize must_== 39 + filePart.filename must_== "" + } + } + parts.file("file5") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "the fifth file (with empty filename)\r\n" + filePart.fileSize must_== 38 + filePart.filename must_== "" + } + } + parts.file("empty_file_empty_filename") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "" + filePart.fileSize must_== 0 + filePart.filename must_== "" + } + } + parts.file("empty_file_bottom") must beSome.like { + case filePart => { + PlayIO.readFileAsString(filePart.ref) must_== "" + filePart.fileSize must_== 0 + filePart.filename must_== "empty_file_not_followed_by_any_other_part.txt" + } + } + parts.badParts must haveLength(0) + } + } + def withClientAndServer[T](totalSpace: Long)(block: WSClient => T) = { Server.withApplicationFromContext() { context => new BuiltInComponentsFromContext(context) with NoHttpFiltersComponents { @@ -224,6 +325,20 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { checkResult(result) } + "parse some content with empty file allowed" in new WithApplication() { + val parser = parse + .multipartFormData(allowEmptyFiles = true) + .apply( + FakeRequest().withHeaders( + CONTENT_TYPE -> "multipart/form-data; boundary=aabbccddee" + ) + ) + + val result = await(parser.run(Source.single(ByteString(body)))) + + checkResultEmptyFileAllowed(result) + } + "parse some content that arrives one byte at a time" in new WithApplication() { val parser = parse.multipartFormData.apply( FakeRequest().withHeaders( @@ -237,6 +352,21 @@ class MultipartFormDataParserSpec extends PlaySpecification with WsTestClient { checkResult(result) } + "parse some content that arrives one byte at a time with empty file allowed" in new WithApplication() { + val parser = parse + .multipartFormData(allowEmptyFiles = true) + .apply( + FakeRequest().withHeaders( + CONTENT_TYPE -> "multipart/form-data; boundary=aabbccddee" + ) + ) + + val bytes = body.getBytes.map(byte => ByteString(byte)).toVector + val result = await(parser.run(Source(bytes))) + + checkResultEmptyFileAllowed(result) + } + "return bad request for invalid body" in new WithApplication() { val parser = parse.multipartFormData.apply( FakeRequest().withHeaders( diff --git a/core/play/src/main/java/play/mvc/BodyParser.java b/core/play/src/main/java/play/mvc/BodyParser.java index ba9b697ecc9..0678777e14d 100644 --- a/core/play/src/main/java/play/mvc/BodyParser.java +++ b/core/play/src/main/java/play/mvc/BodyParser.java @@ -523,9 +523,19 @@ public MultipartFormData(PlayBodyParsers parsers) { super(parsers.multipartFormData(), JavaParsers::toJavaMultipartFormData); } + public MultipartFormData(PlayBodyParsers parsers, boolean allowEmptyFiles) { + super(parsers.multipartFormData(allowEmptyFiles), JavaParsers::toJavaMultipartFormData); + } + public MultipartFormData(PlayBodyParsers parsers, long maxLength) { super(parsers.multipartFormData(maxLength), JavaParsers::toJavaMultipartFormData); } + + public MultipartFormData(PlayBodyParsers parsers, long maxLength, boolean allowEmptyFiles) { + super( + parsers.multipartFormData(maxLength, allowEmptyFiles), + JavaParsers::toJavaMultipartFormData); + } } /** Don't parse the body. */ @@ -706,19 +716,34 @@ public DelegatingMultipartFormDataBodyParser( this.materializer = materializer; this.errorHandler = errorHandler; this.maxMemoryBufferSize = 102400; // 100k, default for play.http.parser.maxMemoryBuffer - delegate = multipartParser(); + delegate = multipartParser(false); + } + + /** + * @deprecated Deprecated as of 2.9.0. Use {@link + * #DelegatingMultipartFormDataBodyParser(Materializer, long, long, boolean, + * HttpErrorHandler)} instead. + */ + @Deprecated + public DelegatingMultipartFormDataBodyParser( + Materializer materializer, + long maxMemoryBufferSize, + long maxLength, + HttpErrorHandler errorHandler) { + this(materializer, maxMemoryBufferSize, maxLength, false, errorHandler); } public DelegatingMultipartFormDataBodyParser( Materializer materializer, long maxMemoryBufferSize, long maxLength, + boolean allowEmptyFiles, HttpErrorHandler errorHandler) { super(maxLength, errorHandler); this.materializer = materializer; this.maxMemoryBufferSize = maxMemoryBufferSize; this.errorHandler = new JavaHttpErrorHandlerAdapter(errorHandler); - delegate = multipartParser(); + delegate = multipartParser(allowEmptyFiles); } /** @@ -732,10 +757,11 @@ public DelegatingMultipartFormDataBodyParser( createFilePartHandler(); /** Calls out to the Scala API to create a multipart parser. */ - private play.api.mvc.BodyParser> multipartParser() { + private play.api.mvc.BodyParser> multipartParser( + boolean allowEmptyFiles) { ScalaFilePartHandler filePartHandler = new ScalaFilePartHandler(); return Multipart.multipartParser( - maxMemoryBufferSize, filePartHandler, errorHandler, materializer); + maxMemoryBufferSize, allowEmptyFiles, filePartHandler, errorHandler, materializer); } private class ScalaFilePartHandler diff --git a/core/play/src/main/resources/reference.conf b/core/play/src/main/resources/reference.conf index 952ca055e2a..4fe90c4b951 100644 --- a/core/play/src/main/resources/reference.conf +++ b/core/play/src/main/resources/reference.conf @@ -105,6 +105,9 @@ play { # The maximum amount of a request body that should be buffered into disk maxDiskBuffer = 10m + + # If empty multipart/form-data file uploads are allowed (no matter if filename or file is empty) + allowEmptyFiles = false } # Action composition configuration diff --git a/core/play/src/main/scala/play/api/http/HttpConfiguration.scala b/core/play/src/main/scala/play/api/http/HttpConfiguration.scala index 7d7256916d9..92c91d3fa4b 100644 --- a/core/play/src/main/scala/play/api/http/HttpConfiguration.scala +++ b/core/play/src/main/scala/play/api/http/HttpConfiguration.scala @@ -157,8 +157,13 @@ case class FlashConfiguration( * * @param maxMemoryBuffer The maximum size that a request body that should be buffered in memory. * @param maxDiskBuffer The maximum size that a request body should be buffered on disk. + * @param allowEmptyFiles If empty file uploads are allowed (no matter if filename or file is empty) */ -case class ParserConfiguration(maxMemoryBuffer: Long = 102400, maxDiskBuffer: Long = 10485760) +case class ParserConfiguration( + maxMemoryBuffer: Long = 102400, + maxDiskBuffer: Long = 10485760, + allowEmptyFiles: Boolean = false +) /** * Configuration for action composition. @@ -240,7 +245,8 @@ object HttpConfiguration { parser = ParserConfiguration( maxMemoryBuffer = config.getDeprecated[ConfigMemorySize]("play.http.parser.maxMemoryBuffer", "parsers.text.maxLength").toBytes, - maxDiskBuffer = config.get[ConfigMemorySize]("play.http.parser.maxDiskBuffer").toBytes + maxDiskBuffer = config.get[ConfigMemorySize]("play.http.parser.maxDiskBuffer").toBytes, + allowEmptyFiles = config.get[Boolean]("play.http.parser.allowEmptyFiles") ), actionComposition = ActionCompositionConfiguration( controllerAnnotationsFirst = config.get[Boolean]("play.http.actionComposition.controllerAnnotationsFirst"), diff --git a/core/play/src/main/scala/play/api/mvc/BodyParsers.scala b/core/play/src/main/scala/play/api/mvc/BodyParsers.scala index 8a861f0ff4a..9cdd9d2f5b3 100644 --- a/core/play/src/main/scala/play/api/mvc/BodyParsers.scala +++ b/core/play/src/main/scala/play/api/mvc/BodyParsers.scala @@ -451,6 +451,17 @@ trait PlayBodyParsers extends BodyParserUtils { */ def DefaultMaxDiskLength: Long = config.maxDiskBuffer + /** + * If empty file uploads are allowed (no matter if filename or file is empty) + * + * You can configure it in application.conf: + * + * {{{ + * play.http.parser.allowEmptyFiles = true + * }}} + */ + def DefaultAllowEmptyFileUploads: Boolean = config.allowEmptyFiles + // -- Text parser /** @@ -922,8 +933,11 @@ trait PlayBodyParsers extends BodyParserUtils { case Some("multipart/form-data") => logger.trace("Parsing AnyContent as multipartFormData") - multipartFormData(Multipart.handleFilePartAsTemporaryFile(temporaryFileCreator), maxLengthOrDefaultLarge) - .apply(request) + multipartFormData( + Multipart.handleFilePartAsTemporaryFile(temporaryFileCreator), + maxLengthOrDefaultLarge, + DefaultAllowEmptyFileUploads + ).apply(request) .map(_.right.map(m => AnyContentAsMultipartFormData(m))) case _ => @@ -948,22 +962,58 @@ trait PlayBodyParsers extends BodyParserUtils { def multipartFormData(maxLength: Long): BodyParser[MultipartFormData[TemporaryFile]] = multipartFormData(Multipart.handleFilePartAsTemporaryFile(temporaryFileCreator), maxLength) + /** + * Parse the content as multipart/form-data + * + * @param allowEmptyFiles If empty file uploads are allowed (no matter if filename or file is empty) + */ + def multipartFormData(allowEmptyFiles: Boolean): BodyParser[MultipartFormData[TemporaryFile]] = + multipartFormData(Multipart.handleFilePartAsTemporaryFile(temporaryFileCreator), allowEmptyFiles = allowEmptyFiles) + + /** + * Parse the content as multipart/form-data + * + * @param maxLength Max length (in bytes) allowed or returns EntityTooLarge HTTP response. + * @param allowEmptyFiles If empty file uploads are allowed (no matter if filename or file is empty) + */ + def multipartFormData(maxLength: Long, allowEmptyFiles: Boolean): BodyParser[MultipartFormData[TemporaryFile]] = + multipartFormData(Multipart.handleFilePartAsTemporaryFile(temporaryFileCreator), maxLength, allowEmptyFiles) + + /** + * Parse the content as multipart/form-data + * + * @param filePartHandler Handles file parts. + * @param maxLength Max length (in bytes) allowed or returns EntityTooLarge HTTP response. + * + * @see [[DefaultMaxDiskLength]] + * @see [[Results.EntityTooLarge]] + * + * @deprecated Since 2.9.0. Use the overloaded multipartFormData method that takes the allowEmptyFiles flag. + */ + @deprecated("Use the overloaded multipartFormData method that takes the allowEmptyFiles flag", "2.9.0") + def multipartFormData[A]( + filePartHandler: Multipart.FilePartHandler[A], + maxLength: Long + ): BodyParser[MultipartFormData[A]] = multipartFormData(filePartHandler, maxLength, false) + /** * Parse the content as multipart/form-data * * @param filePartHandler Handles file parts. * @param maxLength Max length (in bytes) allowed or returns EntityTooLarge HTTP response. + * @param allowEmptyFiles If empty file uploads are allowed (no matter if filename or file is empty) * * @see [[DefaultMaxDiskLength]] * @see [[Results.EntityTooLarge]] */ def multipartFormData[A]( filePartHandler: Multipart.FilePartHandler[A], - maxLength: Long = DefaultMaxDiskLength + maxLength: Long = DefaultMaxDiskLength, + allowEmptyFiles: Boolean = DefaultAllowEmptyFileUploads ): BodyParser[MultipartFormData[A]] = { BodyParser("multipartFormData") { request => val bodyAccumulator = - Multipart.multipartParser(DefaultMaxTextLength, filePartHandler, errorHandler).apply(request) + Multipart.multipartParser(DefaultMaxTextLength, allowEmptyFiles, filePartHandler, errorHandler).apply(request) enforceMaxLength(request, maxLength, bodyAccumulator) } } diff --git a/core/play/src/main/scala/play/core/parsers/Multipart.scala b/core/play/src/main/scala/play/core/parsers/Multipart.scala index 4a507bd20f4..42b2a40ba95 100644 --- a/core/play/src/main/scala/play/core/parsers/Multipart.scala +++ b/core/play/src/main/scala/play/core/parsers/Multipart.scala @@ -43,10 +43,25 @@ object Multipart { * Parses the stream into a stream of [[play.api.mvc.MultipartFormData.Part]] to be handled by `partHandler`. * * @param maxMemoryBufferSize The maximum amount of data to parse into memory. + * @param errorHandler The error handler to call when an error occurs. * @param partHandler The accumulator to handle the parts. + * @deprecated Since 2.9.0. Use the overloaded partParser method that takes the allowEmptyFiles flag. */ + @deprecated("Use the overloaded partParser method that takes the allowEmptyFiles flag", "2.9.0") def partParser[A](maxMemoryBufferSize: Long, errorHandler: HttpErrorHandler)( partHandler: Accumulator[Part[Source[ByteString, _]], Either[Result, A]] + )(implicit mat: Materializer): BodyParser[A] = partParser(maxMemoryBufferSize, false, errorHandler)(partHandler) + + /** + * Parses the stream into a stream of [[play.api.mvc.MultipartFormData.Part]] to be handled by `partHandler`. + * + * @param maxMemoryBufferSize The maximum amount of data to parse into memory. + * @param allowEmptyFiles If file uploads are allowed to contain no data in the body + * @param errorHandler The error handler to call when an error occurs. + * @param partHandler The accumulator to handle the parts. + */ + def partParser[A](maxMemoryBufferSize: Long, allowEmptyFiles: Boolean, errorHandler: HttpErrorHandler)( + partHandler: Accumulator[Part[Source[ByteString, _]], Either[Result, A]] )(implicit mat: Materializer): BodyParser[A] = BodyParser { request => val maybeBoundary = for { mt <- request.mediaType @@ -57,7 +72,7 @@ object Multipart { maybeBoundary .map { boundary => val multipartFlow = Flow[ByteString] - .via(new BodyPartParser(boundary, maxMemoryBufferSize, maxHeaderBuffer)) + .via(new BodyPartParser(boundary, maxMemoryBufferSize, maxHeaderBuffer, allowEmptyFiles)) .splitWhen(_.isLeft) .prefixAndTail(1) .map { @@ -85,13 +100,32 @@ object Multipart { * * @param maxMemoryBufferSize The maximum amount of data to parse into memory. * @param filePartHandler The accumulator to handle the file parts. + * @param errorHandler The error handler to call when an error occurs. + * @deprecated Since 2.9.0. Use the overloaded multipartParser method that takes the allowEmptyFiles flag. */ + @deprecated("Use the overloaded multipartParser method that takes the allowEmptyFiles flag", "2.9.0") def multipartParser[A]( maxMemoryBufferSize: Long, filePartHandler: FilePartHandler[A], errorHandler: HttpErrorHandler + )(implicit mat: Materializer): BodyParser[MultipartFormData[A]] = + multipartParser(maxMemoryBufferSize, false, filePartHandler, errorHandler) + + /** + * Parses the request body into a Multipart body. + * + * @param maxMemoryBufferSize The maximum amount of data to parse into memory. + * @param allowEmptyFiles If empty file uploads are allowed (no matter if filename or file is empty) + * @param filePartHandler The accumulator to handle the file parts. + * @param errorHandler The error handler to call when an error occurs. + */ + def multipartParser[A]( + maxMemoryBufferSize: Long, + allowEmptyFiles: Boolean, + filePartHandler: FilePartHandler[A], + errorHandler: HttpErrorHandler )(implicit mat: Materializer): BodyParser[MultipartFormData[A]] = BodyParser { request => - partParser(maxMemoryBufferSize, errorHandler) { + partParser(maxMemoryBufferSize, allowEmptyFiles, errorHandler) { val handleFileParts = Flow[Part[Source[ByteString, _]]].mapAsync(1) { case filePart: FilePart[Source[ByteString, _]] => filePartHandler(FileInfo(filePart.key, filePart.filename, filePart.contentType, filePart.dispositionType)) @@ -217,7 +251,7 @@ object Multipart { dispositionType <- values.keys.find(key => key == "form-data" || key == "file") partName <- values.get("name") - fileName <- values.get("filename").filter(_.trim.nonEmpty) + fileName <- values.get("filename") contentType = headers.get("content-type") } yield (partName, fileName, contentType, dispositionType) } @@ -274,8 +308,18 @@ object Multipart { * * see: http://tools.ietf.org/html/rfc2046#section-5.1.1 */ - private final class BodyPartParser(boundary: String, maxMemoryBufferSize: Long, maxHeaderSize: Int) - extends GraphStage[FlowShape[ByteString, RawPart]] { + private final class BodyPartParser( + boundary: String, + maxMemoryBufferSize: Long, + maxHeaderSize: Int, + allowEmptyFiles: Boolean + ) extends GraphStage[FlowShape[ByteString, RawPart]] { + + @deprecated("Use the main constructor", "2.9.0") + def this(boundary: String, maxMemoryBufferSize: Long, maxHeaderSize: Int) { + this(boundary, maxMemoryBufferSize, maxHeaderSize, false) + } + require(boundary.nonEmpty, "'boundary' parameter of multipart Content-Type must be non-empty") require( boundary.charAt(boundary.length - 1) != ' ', @@ -393,18 +437,28 @@ object Multipart { val totalMemoryBufferSize = memoryBufferSize + headersSize headers match { - case FileInfoMatcher(partName, fileName, contentType, dispositionType) => - checkEmptyBody(input, partStart, totalMemoryBufferSize)(newInput => - handleFilePart( - newInput, - partStart, - totalMemoryBufferSize, - partName, - fileName, - contentType, - dispositionType - ) - )(newInput => handleBadPart(newInput, partStart, totalMemoryBufferSize, headers)) + case FileInfoMatcher(partName, fileName, contentType, dispositionType) => { + def processFilePart(in: ByteString) = handleFilePart( + in, + partStart, + totalMemoryBufferSize, + partName, + fileName, + contentType, + dispositionType + ) + if (allowEmptyFiles) { + processFilePart(input) + } else { + if (fileName.trim.nonEmpty) { + checkEmptyBody(input, partStart, totalMemoryBufferSize)(newInput => processFilePart(newInput))( + newInput => handleBadPart(newInput, partStart, totalMemoryBufferSize, headers) + ) + } else { + handleBadPart(input, partStart, totalMemoryBufferSize, headers) + } + } + } case PartInfoMatcher(name) => handleDataPart(input, partStart, memoryBufferSize + name.length, name) case _ => diff --git a/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala b/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala index 0b35fff085a..e3710577a9e 100644 --- a/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala +++ b/core/play/src/test/scala/play/api/http/HttpConfigurationSpec.scala @@ -27,6 +27,7 @@ class HttpConfigurationSpec extends Specification { "play.http.context" -> "/", "play.http.parser.maxMemoryBuffer" -> "10k", "play.http.parser.maxDiskBuffer" -> "20k", + "play.http.parser.allowEmptyFiles" -> "true", "play.http.actionComposition.controllerAnnotationsFirst" -> "true", "play.http.actionComposition.executeActionCreatorActionFirst" -> "true", "play.http.cookies.strict" -> "true", @@ -118,6 +119,11 @@ class HttpConfigurationSpec extends Specification { httpConfiguration.parser.maxDiskBuffer must beEqualTo(20 * 1024) } + "configure empty file uploads" in { + val httpConfiguration = new HttpConfiguration.HttpConfigurationProvider(configuration, environment).get + httpConfiguration.parser.allowEmptyFiles must beTrue + } + "configure cookies encoder/decoder" in { val httpConfiguration = new HttpConfiguration.HttpConfigurationProvider(configuration, environment).get httpConfiguration.cookies.strict must beTrue diff --git a/core/play/src/test/scala/play/mvc/DummyDelegatingMultipartFormDataBodyParser.java b/core/play/src/test/scala/play/mvc/DummyDelegatingMultipartFormDataBodyParser.java index 2a2d2eaee42..fd4af9d846d 100644 --- a/core/play/src/test/scala/play/mvc/DummyDelegatingMultipartFormDataBodyParser.java +++ b/core/play/src/test/scala/play/mvc/DummyDelegatingMultipartFormDataBodyParser.java @@ -30,8 +30,9 @@ public DummyDelegatingMultipartFormDataBodyParser( Materializer materializer, long maxMemoryBufferSize, long maxLength, + boolean allowEmptyFiles, HttpErrorHandler errorHandler) { - super(materializer, maxMemoryBufferSize, maxLength, errorHandler); + super(materializer, maxMemoryBufferSize, maxLength, allowEmptyFiles, errorHandler); } @Override @@ -68,4 +69,4 @@ private File generateTempFile() { throw new IllegalStateException(e); } } -} \ No newline at end of file +} diff --git a/core/play/src/test/scala/play/mvc/MaxLengthBodyParserSpec.scala b/core/play/src/test/scala/play/mvc/MaxLengthBodyParserSpec.scala index 67427d292f1..1180833a42f 100644 --- a/core/play/src/test/scala/play/mvc/MaxLengthBodyParserSpec.scala +++ b/core/play/src/test/scala/play/mvc/MaxLengthBodyParserSpec.scala @@ -87,7 +87,7 @@ class MaxLengthBodyParserSpec extends Specification with AfterAll with MustMatch ByteString("--aabbccddeee--") // 15 bytes ), ( - new DummyDelegatingMultipartFormDataBodyParser(materializer, 102400, 15, defaultHttpErrorHandler), + new DummyDelegatingMultipartFormDataBodyParser(materializer, 102400, 15, false, defaultHttpErrorHandler), Some("multipart/form-data; boundary=aabbccddeee"), ByteString("--aabbccddeee--") // 15 bytes ), diff --git a/documentation/manual/working/javaGuide/main/upload/code/JavaFileUpload.java b/documentation/manual/working/javaGuide/main/upload/code/JavaFileUpload.java index 3783e0a469b..96ed979371e 100644 --- a/documentation/manual/working/javaGuide/main/upload/code/JavaFileUpload.java +++ b/documentation/manual/working/javaGuide/main/upload/code/JavaFileUpload.java @@ -62,6 +62,7 @@ public MultipartFormDataWithFileBodyParser( materializer, config.parser().maxMemoryBuffer(), // Small buffer used for parsing the body config.parser().maxDiskBuffer(), // Maximum allowed length of the request body + config.parser().allowEmptyFiles(), errorHandler); } diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 72f501c3329..f2b88e7d773 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -225,6 +225,14 @@ object BuildSettings { ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.typedmap.DefaultTypedMap.-"), // Remove outdated (internal) method ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.streams.Execution.defaultExecutionContext"), + // Add allowEmptyFiles config to allow empty file uploads + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ParserConfiguration.apply"), + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ParserConfiguration.copy"), + ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.http.ParserConfiguration.this"), + ProblemFilters.exclude[IncompatibleSignatureProblem]("play.api.http.ParserConfiguration.curried"), + ProblemFilters.exclude[IncompatibleSignatureProblem]("play.api.http.ParserConfiguration.tupled"), + ProblemFilters.exclude[IncompatibleSignatureProblem]("play.api.http.ParserConfiguration.unapply"), + ProblemFilters.exclude[MissingTypesProblem]("play.api.http.ParserConfiguration$"), // Add withExtraServerConfiguration() to append server config to endpoints ProblemFilters .exclude[ReversedMissingMethodProblem]("play.api.test.ServerEndpointRecipe.withExtraServerConfiguration"),