Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Escape Content-Disposition params according to WHATWG HTML living standard #11571

Merged
merged 1 commit into from Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -170,6 +170,26 @@ trait JavaWSSpec
body.path("file").textValue() must_== "This is a test asset."
}

"sending a multipart form body with escaped 'name' and 'filename' params" in withEchoServer { ws =>
val file = new File(this.getClass.getResource("/testassets/bar.txt").toURI).toPath
val dp = new Http.MultipartFormData.DataPart("f\ni\re\"l\nd1", "world")
val fp = new Http.MultipartFormData.FilePart(
"f\"i\rl\nef\"ie\nld\r1",
"f\rir\"s\ntf\ril\"e\n.txt",
"text/plain",
FileIO.fromPath(file).asJava
)
val source = akka.stream.javadsl.Source.from(util.Arrays.asList(dp, fp))

val res = ws.url("/post").post(source)
val body = res.toCompletableFuture.get().getBody()

body must contain("Content-Disposition: form-data; name=\"f%0Ai%0De%22l%0Ad1\"")
body must contain(
"Content-Disposition: form-data; name=\"f%22i%0Dl%0Aef%22ie%0Ald%0D1\"; filename=\"f%0Dir%22s%0Atf%0Dil%22e%0A.txt\""
)
}

"send a multipart request body via multipartBody()" in withServer { ws =>
val file = new File(this.getClass.getResource("/testassets/bar.txt").toURI)
val dp = new Http.MultipartFormData.DataPart("hello", "world")
Expand Down
Expand Up @@ -113,6 +113,25 @@ trait ScalaWSSpec
(body \ "file").toOption must beSome(JsString("This is a test asset."))
}

"send a multipart request body with escaped 'name' and 'filename' params" in withEchoServer { ws =>
val file = new File(this.getClass.getResource("/testassets/foo.txt").toURI)
val dp = MultipartFormData.DataPart("f\ni\re\"l\nd1", "world")
val fp = MultipartFormData.FilePart(
"f\"i\rl\nef\"ie\nld\r1",
"f\rir\"s\ntf\ril\"e\n.txt",
None,
FileIO.fromPath(file.toPath)
)
val source = Source(List(dp, fp))
val res = ws.url("/post").withBody(source).withMethod("POST").execute()
val body = await(res).body

body must contain("""Content-Disposition: form-data; name="f%0Ai%0De%22l%0Ad1"""")
body must contain(
"""Content-Disposition: form-data; name="f%22i%0Dl%0Aef%22ie%0Ald%0D1"; filename="f%0Dir%22s%0Atf%0Dil%22e%0A.txt""""
)
}

"not throw an exception while signing requests" >> {
val calc = new CustomSigner

Expand Down
62 changes: 56 additions & 6 deletions core/play-java/src/test/java/play/mvc/RequestBuilderTest.java
Expand Up @@ -4,9 +4,15 @@

package play.mvc;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.*;

import akka.stream.javadsl.FileIO;
import akka.stream.javadsl.Source;
import akka.util.ByteString;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
Expand All @@ -15,6 +21,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.hamcrest.CoreMatchers;
import org.junit.Test;
import play.api.Application;
import play.api.Play;
Expand Down Expand Up @@ -384,16 +391,59 @@ public void multipartForm() throws ExecutionException, InterruptedException {
Play.stop(app);
}

@Test
public void multipartForm_bodyRaw_correctEscapedParams() throws URISyntaxException, IOException {
Application app = new GuiceApplicationBuilder().build();
Play.start(app);

File file = new File(this.getClass().getResource("/testassets/foo.txt").toURI());
Http.MultipartFormData.Part<Source<ByteString, ?>> filePart =
new Http.MultipartFormData.FilePart<>(
"f\"i\rl\nef\"ie\nld\r1",
"f\rir\"s\ntf\ril\"e\n.txt",
"text/plain",
FileIO.fromPath(file.toPath()),
java.nio.file.Files.size(file.toPath()));

Http.MultipartFormData.DataPart dataPart =
new Http.MultipartFormData.DataPart("f\ni\re\"l\nd1", "value1");

TemporaryFileCreator temporaryFileCreator =
app.injector().instanceOf(TemporaryFileCreator.class);
final Request request =
new RequestBuilder()
.uri("http://playframework.com/")
// bodyRaw, as its name tells us, saves the body in raw bytes.
// To do that it needs to render the body, so bodyRaw(...) goes through
// play.mvc.MultipartFormatter#transform(...), so eventually
// play.core.formatters.Multipart, which renders the multipart/form-data elements and
// escapes params, will be used
.bodyRaw(List.of(dataPart, filePart), temporaryFileCreator, app.materializer())
.build();

String body =
request.body().asBytes().utf8String(); // Let's get the text representation of the bytes
assertThat(
body,
CoreMatchers.containsString("Content-Disposition: form-data; name=\"f%0Ai%0De%22l%0Ad1\""));
assertThat(
body,
CoreMatchers.containsString(
"Content-Disposition: form-data; name=\"f%22i%0Dl%0Aef%22ie%0Ald%0D1\"; filename=\"f%0Dir%22s%0Atf%0Dil%22e%0A.txt\""));

Play.stop(app);
}

@Test
public void multipartFormContentLength() {
final Map<String, String[]> dataParts = new HashMap<>();
dataParts.put("field1", new String[] {"value1"});
dataParts.put("f\ni\re\"l\nd1", new String[] {"value1"});
dataParts.put("field2", new String[] {"value2-1", "value2.2"});

final List<Http.MultipartFormData.FilePart> fileParts = new ArrayList<>();
fileParts.add(
new Http.MultipartFormData.FilePart<>(
"filefield1", "firstfile.txt", "text/plain", "abc", 3));
"f\"i\rl\nef\"ie\nld\r1", "f\rir\"s\ntf\ril\"e\n.txt", "text/plain", "abc", 3));
fileParts.add(
new Http.MultipartFormData.FilePart<>(
"file_field_2", "secondfile.txt", "text/plain", "hello world", 11));
Expand All @@ -411,10 +461,10 @@ public void multipartFormContentLength() {
// Now let's check the calculated Content-Length. The request body should look like this when
// stringified:
// (You can copy the lines, save it with an editor with UTF-8 encoding and Windows line endings
// (\r\n) and the file size should be 542 bytes
// (\r\n) and the file size should be 590 bytes
/*
--somerandomboundary
Content-Disposition: form-data; name="field1"
Content-Disposition: form-data; name="f%0Ai%0De%22l%0Ad1"

value1
--somerandomboundary
Expand All @@ -426,7 +476,7 @@ public void multipartFormContentLength() {

value2.2
--somerandomboundary
Content-Disposition: form-data; name="filefield1"; filename="firstfile.txt"
Content-Disposition: form-data; name="f%22i%0Dl%0Aef%22ie%0Ald%0D1"; filename="f%0Dir%22s%0Atf%0Dil%22e%0A.txt"
Content-Type: text/plain

abc
Expand All @@ -437,6 +487,6 @@ public void multipartFormContentLength() {
hello world
--somerandomboundary--
*/
assertEquals(request.header(Http.HeaderNames.CONTENT_LENGTH).get(), "542");
assertEquals(request.header(Http.HeaderNames.CONTENT_LENGTH).get(), "590");
}
}
1 change: 1 addition & 0 deletions core/play-java/src/test/resources/testassets/foo.txt
@@ -0,0 +1 @@
abc
8 changes: 6 additions & 2 deletions core/play/src/main/java/play/mvc/Http.java
Expand Up @@ -4,6 +4,8 @@

package play.mvc;

import static play.core.formatters.Multipart.escapeParamWithHTML5Strategy;

import akka.stream.Materializer;
import akka.stream.javadsl.Sink;
import akka.stream.javadsl.Source;
Expand Down Expand Up @@ -731,9 +733,11 @@ private int partLength(
+ "Content-Disposition: "
+ dispositionType
+ "; name=\""
+ name
+ escapeParamWithHTML5Strategy(name)
+ "\""
+ (filename != null ? "; filename=\"" + filename + "\"" : "")
+ (filename != null
? "; filename=\"" + escapeParamWithHTML5Strategy(filename) + "\""
: "")
+ "\r\n"
+ (contentType != null ? "Content-Type: " + contentType + "\r\n" : "")
+ "\r\n"
Expand Down
7 changes: 4 additions & 3 deletions core/play/src/main/scala/play/api/http/Writeable.scala
Expand Up @@ -161,16 +161,17 @@ trait DefaultWriteables extends LowPriorityWriteables {
.flatMap {
case (name, values) =>
values.map { value =>
s"""--$resolvedBoundary\r\n${HeaderNames.CONTENT_DISPOSITION}: form-data; name="$name"\r\n\r\n$value\r\n"""
s"""--$resolvedBoundary\r\n${HeaderNames.CONTENT_DISPOSITION}: form-data; name="${Multipart
.escapeParamWithHTML5Strategy(name)}"\r\n\r\n$value\r\n"""
}
}
.mkString("")
codec.encode(dataParts)
}

def filePartHeader(file: FilePart[A]) = {
val name = s""""${file.key}""""
val filename = s""""${file.filename}""""
val name = s""""${Multipart.escapeParamWithHTML5Strategy(file.key)}""""
val filename = s""""${Multipart.escapeParamWithHTML5Strategy(file.filename)}""""
val contentType = file.contentType
.map { ct => s"${HeaderNames.CONTENT_TYPE}: $ct\r\n" }
.getOrElse("")
Expand Down
21 changes: 19 additions & 2 deletions core/play/src/main/scala/play/core/formatters/Multipart.scala
Expand Up @@ -62,6 +62,21 @@ object Multipart {
new String(bytes.toArray, US_ASCII)
}

/**
* Helper function to escape a single header parameter using the HTML5 strategy.
* (The alternative would be the strategy defined by RFC5987)
* Particularly useful for Content-Disposition header parameters which might contain
* non-ASCII values, like file names.
* This follows the "WHATWG HTML living standard" section 4.10.21.8 and matches
* the behavior of curl and modern browsers.
* See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data
*/
def escapeParamWithHTML5Strategy(value: String) =
value
.replace("\"", "%22")
.replace("\r", "%0D")
.replace("\n", "%0A")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main part of this pull request, everything else just make use of this method and the rest is testing.
The link the the "WHATWG HTML living standard" section 4.10.21.8: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data


private sealed trait Formatter {
def ~~(ch: Char): this.type

Expand Down Expand Up @@ -212,8 +227,10 @@ object Multipart {
contentDisposition: String,
filename: Option[String]
): Unit = {
f ~~ "Content-Disposition: " ~~ dispositionType ~~ "; name=" ~~ '"' ~~ contentDisposition ~~ '"'
filename.foreach { name => f ~~ "; filename=" ~~ '"' ~~ name ~~ '"' }
f ~~ "Content-Disposition: " ~~ dispositionType ~~ "; name=" ~~ '"' ~~ escapeParamWithHTML5Strategy(
contentDisposition
) ~~ '"'
filename.foreach { name => f ~~ "; filename=" ~~ '"' ~~ escapeParamWithHTML5Strategy(name) ~~ '"' }
f ~~ CrLf
}

Expand Down
36 changes: 32 additions & 4 deletions core/play/src/test/scala/play/api/http/WriteableSpec.scala
Expand Up @@ -51,6 +51,28 @@ class WriteableSpec extends Specification {
transformed.utf8String must contain("file part value")
}

"escape 'name' and 'filename' params" in {
val multipartFormData =
createMultipartFormData[String](
"file part value",
data => Some(ByteString.fromString(data)),
dataPartKey = "ab\"cd\nef\rgh\"ij\rk\nl",
filePartKey = "mn\"op\nqr\rst\"uv\rw\nx",
filePartFilename = "fo\"o\no\rb\"a\ra\nar.p\"df"
)
val codec = Codec.utf_8

val writeable = Writeable.writeableOf_MultipartFormData[String](None)(codec)
val transformed: ByteString = writeable.transform(multipartFormData)

transformed.utf8String must contain("""Content-Disposition: form-data; name="ab%22cd%0Aef%0Dgh%22ij%0Dk%0Al"""")
transformed.utf8String must contain(
"""Content-Disposition: form-data; name="mn%22op%0Aqr%0Dst%22uv%0Dw%0Ax"; filename="fo%22o%0Ao%0Db%22a%0Da%0Aar.p%22df""""
)
transformed.utf8String must contain("Content-Type: text/plain")
transformed.utf8String must contain("file part value")
}

"use multipart/form-data content-type" in {
val codec = Codec.utf_8
val writeable = Writeable.writeableOf_MultipartFormData(None)(codec)
Expand All @@ -70,15 +92,21 @@ class WriteableSpec extends Specification {
}
}

def createMultipartFormData[A](ref: A, refToBytes: A => Option[ByteString] = (a: A) => None): MultipartFormData[A] = {
def createMultipartFormData[A](
ref: A,
refToBytes: A => Option[ByteString] = (a: A) => None,
dataPartKey: String = "name",
filePartKey: String = "thefile",
filePartFilename: String = "something.text"
): MultipartFormData[A] = {
MultipartFormData[A](
dataParts = Map(
"name" -> Seq("value")
dataPartKey -> Seq("value")
),
files = Seq(
FilePart[A](
key = "thefile",
filename = "something.text",
key = filePartKey,
filename = filePartFilename,
contentType = Some("text/plain"),
ref = ref,
refToBytes = refToBytes
Expand Down
@@ -0,0 +1 @@
abc
Expand Up @@ -8,6 +8,8 @@ import java.util
import java.nio.charset.StandardCharsets

import akka.stream.Materializer
import akka.stream.scaladsl.FileIO
import akka.stream.scaladsl.Source
import akka.util.ByteString
import akka.util.Timeout
import org.specs2.concurrent.ExecutionEnv
Expand Down Expand Up @@ -412,6 +414,49 @@ class AhcWSSpec(implicit ee: ExecutionEnv)
rep.body must ===("gziped response")
}

def multipartFormDataFakeApp = {
val routes: (Application) => PartialFunction[(String, String), Handler] = { (app: Application) =>
{
case ("POST", "/") =>
val action = app.injector.instanceOf(classOf[DefaultActionBuilder])
action { request =>
Results.Ok(
request.body.asMultipartFormData
.map(mpf => {
"dataPart name: " + mpf.dataParts.keys.mkString(",") + "\n" +
"filePart names: " + mpf.files.map(_.key).mkString(",") + "\n" +
"filePart filenames: " + mpf.files.map(_.filename).mkString(",")
})
.getOrElse("")
)
}
}
}

GuiceApplicationBuilder().appRoutes(routes).build()
}

"escape 'name' and 'filename' params of a multipart form body" in new WithServer(multipartFormDataFakeApp) {
{
val wsClient = app.injector.instanceOf(classOf[play.api.libs.ws.WSClient])
val file = new java.io.File(this.getClass.getResource("/testassets/foo.txt").toURI)
val dp = MultipartFormData.DataPart("h\"e\rl\nl\"o\rwo\nrld", "world")
val fp =
MultipartFormData.FilePart("u\"p\rl\no\"a\rd", "f\"o\ro\n_\"b\ra\nr.txt", None, FileIO.fromPath(file.toPath))
val source = Source(List(dp, fp))
val futureResponse = wsClient.url(s"http://localhost:${Helpers.testServerPort}/").post(source)

// This test could experience CI timeouts. Give it more time.
val reallyLongTimeout = Timeout(defaultAwaitTimeout.duration * 3)
val rep = await(futureResponse)(reallyLongTimeout)

rep.status must ===(200)
rep.body must be_==("""dataPart name: h%22e%0Dl%0Al%22o%0Dwo%0Arld
|filePart names: u%22p%0Dl%0Ao%22a%0Dd
|filePart filenames: f%22o%0Do%0A_%22b%0Da%0Ar.txt""".stripMargin)
}
}

"Ahc WS Response" should {
"get cookies from an AHC response" in {
val ahcResponse: AHCResponse = mock[AHCResponse]
Expand Down