diff --git a/dom/base/URLSearchParams.h b/dom/base/URLSearchParams.h index 173628fb58ce..ee7ff84e8dd8 100644 --- a/dom/base/URLSearchParams.h +++ b/dom/base/URLSearchParams.h @@ -80,6 +80,17 @@ public: Serialize(aRetval); } + typedef void (*ParamFunc)(const nsString& aName, const nsString& aValue, + void* aClosure); + + void + ForEach(ParamFunc aFunc, void* aClosure) + { + for (uint32_t i = 0; i < mSearchParams.Length(); ++i) { + aFunc(mSearchParams[i].mKey, mSearchParams[i].mValue, aClosure); + } + } + private: void AppendInternal(const nsAString& aName, const nsAString& aValue); diff --git a/dom/bindings/Errors.msg b/dom/bindings/Errors.msg index bdf0318e3045..2c828efa1026 100644 --- a/dom/bindings/Errors.msg +++ b/dom/bindings/Errors.msg @@ -73,3 +73,4 @@ MSG_DEF(MSG_INVALID_RESPONSE_STATUSCODE_ERROR, 0, JSEXN_RANGEERR, "Invalid respo MSG_DEF(MSG_INVALID_REDIRECT_STATUSCODE_ERROR, 0, JSEXN_RANGEERR, "Invalid redirect status code.") MSG_DEF(MSG_INVALID_URL_SCHEME, 2, JSEXN_TYPEERR, "{0} URL {1} must be either http:// or https://.") MSG_DEF(MSG_RESPONSE_URL_IS_NULL, 0, JSEXN_TYPEERR, "Cannot set Response.finalURL when Response.url is null.") +MSG_DEF(MSG_BAD_FORMDATA, 0, JSEXN_TYPEERR, "Could not parse content as FormData.") diff --git a/dom/fetch/Fetch.cpp b/dom/fetch/Fetch.cpp index bad7918727de..788ff4edd482 100644 --- a/dom/fetch/Fetch.cpp +++ b/dom/fetch/Fetch.cpp @@ -12,8 +12,10 @@ #include "nsIUnicodeDecoder.h" #include "nsIUnicodeEncoder.h" +#include "nsCharSeparatedTokenizer.h" #include "nsDOMString.h" #include "nsNetUtil.h" +#include "nsReadableUtils.h" #include "nsStreamUtils.h" #include "nsStringStream.h" @@ -515,6 +517,382 @@ ExtractFromURLSearchParams(const URLSearchParams& aParams, aContentType = NS_LITERAL_CSTRING("application/x-www-form-urlencoded;charset=UTF-8"); return NS_NewStringInputStream(aStream, serialized); } + +void +FillFormData(const nsString& aName, const nsString& aValue, void* aFormData) +{ + MOZ_ASSERT(aFormData); + nsFormData* fd = static_cast(aFormData); + fd->Append(aName, aValue); +} + +/** + * A simple multipart/form-data parser as defined in RFC 2388 and RFC 2046. + * This does not respect any encoding specified per entry, using UTF-8 + * throughout. This is as the Fetch spec states in the consume body algorithm. + * Borrows some things from Necko's nsMultiMixedConv, but is simpler since + * unlike Necko we do not have to deal with receiving incomplete chunks of data. + * + * This parser will fail the entire parse on any invalid entry, so it will + * never return a partially filled FormData. + * The content-disposition header is used to figure out the name and filename + * entries. The inclusion of the filename parameter decides if the entry is + * inserted into the nsFormData as a string or a File. + * + * File blobs are copies of the underlying data string since we cannot adopt + * char* chunks embedded within the larger body without significant effort. + * FIXME(nsm): Bug 1127552 - We should add telemetry to calls to formData() and + * friends to figure out if Fetch ends up copying big blobs to see if this is + * worth optimizing. + */ +class MOZ_STACK_CLASS FormDataParser +{ +private: + nsRefPtr mFormData; + nsCString mMimeType; + nsCString mData; + + // Entry state, reset in START_PART. + nsCString mName; + nsCString mFilename; + nsCString mContentType; + + enum + { + START_PART, + PARSE_HEADER, + PARSE_BODY, + } mState; + + nsIGlobalObject* mParentObject; + + // Reads over a boundary and sets start to the position after the end of the + // boundary. Returns false if no boundary is found immediately. + bool + PushOverBoundary(const nsACString& aBoundaryString, + nsACString::const_iterator& aStart, + nsACString::const_iterator& aEnd) + { + // We copy the end iterator to keep the original pointing to the real end + // of the string. + nsACString::const_iterator end(aEnd); + const char* beginning = aStart.get(); + if (FindInReadable(aBoundaryString, aStart, end)) { + // We either should find the body immediately, or after 2 chars with the + // 2 chars being '-', everything else is failure. + if ((aStart.get() - beginning) == 0) { + aStart.advance(aBoundaryString.Length()); + return true; + } + + if ((aStart.get() - beginning) == 2) { + if (*(--aStart) == '-' && *(--aStart) == '-') { + aStart.advance(aBoundaryString.Length() + 2); + return true; + } + } + } + + return false; + } + + // Reads over a CRLF and positions start after it. + bool + PushOverLine(nsACString::const_iterator& aStart) + { + if (*aStart == nsCRT::CR && (aStart.size_forward() > 1) && *(++aStart) == nsCRT::LF) { + ++aStart; // advance to after CRLF + return true; + } + + return false; + } + + bool + FindCRLF(nsACString::const_iterator& aStart, + nsACString::const_iterator& aEnd) + { + nsACString::const_iterator end(aEnd); + return FindInReadable(NS_LITERAL_CSTRING("\r\n"), aStart, end); + } + + bool + ParseHeader(nsACString::const_iterator& aStart, + nsACString::const_iterator& aEnd, + bool* aWasEmptyHeader) + { + MOZ_ASSERT(aWasEmptyHeader); + // Set it to a valid value here so we don't forget later. + *aWasEmptyHeader = false; + + const char* beginning = aStart.get(); + nsACString::const_iterator end(aEnd); + if (!FindCRLF(aStart, end)) { + return false; + } + + if (aStart.get() == beginning) { + *aWasEmptyHeader = true; + return true; + } + + nsAutoCString header(beginning, aStart.get() - beginning); + + nsACString::const_iterator headerStart, headerEnd; + header.BeginReading(headerStart); + header.EndReading(headerEnd); + if (!FindCharInReadable(':', headerStart, headerEnd)) { + return false; + } + + nsAutoCString headerName(StringHead(header, headerStart.size_backward())); + headerName.CompressWhitespace(); + if (!NS_IsValidHTTPToken(headerName)) { + return false; + } + + nsAutoCString headerValue(Substring(++headerStart, headerEnd)); + if (!NS_IsReasonableHTTPHeaderValue(headerValue)) { + return false; + } + headerValue.CompressWhitespace(); + + if (headerName.LowerCaseEqualsLiteral("content-disposition")) { + nsCCharSeparatedTokenizer tokenizer(headerValue, ';'); + bool seenFormData = false; + while (tokenizer.hasMoreTokens()) { + const nsDependentCSubstring& token = tokenizer.nextToken(); + if (token.IsEmpty()) { + continue; + } + + if (token.EqualsLiteral("form-data")) { + seenFormData = true; + continue; + } + + if (seenFormData && + StringBeginsWith(token, NS_LITERAL_CSTRING("name="))) { + mName = StringTail(token, token.Length() - 5); + mName.Trim(" \""); + continue; + } + + if (seenFormData && + StringBeginsWith(token, NS_LITERAL_CSTRING("filename="))) { + mFilename = StringTail(token, token.Length() - 9); + mFilename.Trim(" \""); + continue; + } + } + + if (mName.IsVoid()) { + // Could not parse a valid entry name. + return false; + } + } else if (headerName.LowerCaseEqualsLiteral("content-type")) { + mContentType = headerValue; + } + + return true; + } + + // The end of a body is marked by a CRLF followed by the boundary. So the + // CRLF is part of the boundary and not the body, but any prior CRLFs are + // part of the body. This will position the iterator at the beginning of the + // boundary (after the CRLF). + bool + ParseBody(const nsACString& aBoundaryString, + nsACString::const_iterator& aStart, + nsACString::const_iterator& aEnd) + { + const char* beginning = aStart.get(); + + // Find the boundary marking the end of the body. + nsACString::const_iterator end(aEnd); + if (!FindInReadable(aBoundaryString, aStart, end)) { + return false; + } + + // We found a boundary, strip the just prior CRLF, and consider + // everything else the body section. + if (aStart.get() - beginning < 2) { + // Only the first entry can have a boundary right at the beginning. Even + // an empty body will have a CRLF before the boundary. So this is + // a failure. + return false; + } + + // Check that there is a CRLF right before the boundary. + aStart.advance(-2); + + // Skip optional hyphens. + if (*aStart == '-' && *(aStart.get()+1) == '-') { + if (aStart.get() - beginning < 2) { + return false; + } + + aStart.advance(-2); + } + + if (*aStart != nsCRT::CR || *(aStart.get()+1) != nsCRT::LF) { + return false; + } + + nsAutoCString body(beginning, aStart.get() - beginning); + + // Restore iterator to after the \r\n as we promised. + // We do not need to handle the extra hyphens case since our boundary + // parser in PushOverBoundary() + aStart.advance(2); + + if (!mFormData) { + mFormData = new nsFormData(); + } + + NS_ConvertUTF8toUTF16 name(mName); + + if (mFilename.IsVoid()) { + mFormData->Append(name, NS_ConvertUTF8toUTF16(body)); + } else { + // Unfortunately we've to copy the data first since all our strings are + // going to free it. We also need fallible alloc, so we can't just use + // ToNewCString(). + char* copy = static_cast(NS_Alloc(body.Length())); + if (!copy) { + NS_WARNING("Failed to copy File entry body."); + return false; + } + nsCString::const_iterator bodyIter, bodyEnd; + body.BeginReading(bodyIter); + body.EndReading(bodyEnd); + char *p = copy; + while (bodyIter != bodyEnd) { + *p++ = *bodyIter++; + } + p = nullptr; + + nsRefPtr file = + File::CreateMemoryFile(mParentObject, + reinterpret_cast(copy), body.Length(), + NS_ConvertUTF8toUTF16(mFilename), + NS_ConvertUTF8toUTF16(mContentType), /* aLastModifiedDate */ 0); + Optional dummy; + mFormData->Append(name, *file, dummy); + } + + return true; + } + +public: + FormDataParser(const nsACString& aMimeType, const nsACString& aData, nsIGlobalObject* aParent) + : mMimeType(aMimeType), mData(aData), mState(START_PART), mParentObject(aParent) + { + } + + bool + Parse() + { + // Determine boundary from mimetype. + const char* boundaryId = nullptr; + boundaryId = strstr(mMimeType.BeginWriting(), "boundary"); + if (!boundaryId) { + return false; + } + + boundaryId = strchr(boundaryId, '='); + if (!boundaryId) { + return false; + } + + // Skip over '='. + boundaryId++; + + char *attrib = (char *) strchr(boundaryId, ';'); + if (attrib) *attrib = '\0'; + + nsAutoCString boundaryString(boundaryId); + if (attrib) *attrib = ';'; + + boundaryString.Trim(" \""); + + if (boundaryString.Length() == 0) { + return false; + } + + nsACString::const_iterator start, end; + mData.BeginReading(start); + // This should ALWAYS point to the end of data. + // Helpers make copies. + mData.EndReading(end); + + while (start != end) { + switch(mState) { + case START_PART: + mName.SetIsVoid(true); + mFilename.SetIsVoid(true); + mContentType = NS_LITERAL_CSTRING("text/plain"); + + // MUST start with boundary. + if (!PushOverBoundary(boundaryString, start, end)) { + return false; + } + + if (start != end && *start == '-') { + // End of data. + if (!mFormData) { + mFormData = new nsFormData(); + } + return true; + } + + if (!PushOverLine(start)) { + return false; + } + mState = PARSE_HEADER; + break; + + case PARSE_HEADER: + bool emptyHeader; + if (!ParseHeader(start, end, &emptyHeader)) { + return false; + } + + if (!PushOverLine(start)) { + return false; + } + + mState = emptyHeader ? PARSE_BODY : PARSE_HEADER; + break; + + case PARSE_BODY: + if (mName.IsVoid()) { + NS_WARNING("No content-disposition header with a valid name was " + "found. Failing at body parse."); + return false; + } + + if (!ParseBody(boundaryString, start, end)) { + return false; + } + + mState = START_PART; + break; + + default: + MOZ_CRASH("Invalid case"); + } + } + + NS_NOTREACHED("Should never reach here."); + return false; + } + + already_AddRefed FormData() + { + return mFormData.forget(); + } +}; } // anonymous namespace nsresult @@ -1142,6 +1520,38 @@ FetchBody::ContinueConsumeBody(nsresult aStatus, uint32_t aResultLength autoFree.Reset(); return; } + case CONSUME_FORMDATA: { + nsCString data; + data.Adopt(reinterpret_cast(aResult), aResultLength); + autoFree.Reset(); + + if (StringBeginsWith(mMimeType, NS_LITERAL_CSTRING("multipart/form-data"))) { + FormDataParser parser(mMimeType, data, DerivedClass()->GetParentObject()); + if (!parser.Parse()) { + ErrorResult result; + result.ThrowTypeError(MSG_BAD_FORMDATA); + localPromise->MaybeReject(result); + return; + } + + nsRefPtr fd = parser.FormData(); + MOZ_ASSERT(fd); + localPromise->MaybeResolve(fd); + } else if (StringBeginsWith(mMimeType, + NS_LITERAL_CSTRING("application/x-www-form-urlencoded"))) { + nsRefPtr params = new URLSearchParams(); + params->ParseInput(data, /* aObserver */ nullptr); + + nsRefPtr fd = new nsFormData(DerivedClass()->GetParentObject()); + params->ForEach(FillFormData, static_cast(fd)); + localPromise->MaybeResolve(fd); + } else { + ErrorResult result; + result.ThrowTypeError(MSG_BAD_FORMDATA); + localPromise->MaybeReject(result); + } + return; + } case CONSUME_TEXT: // fall through handles early exit. case CONSUME_JSON: { diff --git a/dom/fetch/Fetch.h b/dom/fetch/Fetch.h index 33e75bf69db4..480c04fbaf39 100644 --- a/dom/fetch/Fetch.h +++ b/dom/fetch/Fetch.h @@ -113,6 +113,12 @@ public: return ConsumeBody(CONSUME_BLOB, aRv); } + already_AddRefed + FormData(ErrorResult& aRv) + { + return ConsumeBody(CONSUME_FORMDATA, aRv); + } + already_AddRefed Json(ErrorResult& aRv) { @@ -160,7 +166,7 @@ private: { CONSUME_ARRAYBUFFER, CONSUME_BLOB, - // FormData not supported right now, + CONSUME_FORMDATA, CONSUME_JSON, CONSUME_TEXT, }; diff --git a/dom/tests/mochitest/fetch/fetch_test_framework.js b/dom/tests/mochitest/fetch/fetch_test_framework.js index 88c05761303d..8c6c6936eacf 100644 --- a/dom/tests/mochitest/fetch/fetch_test_framework.js +++ b/dom/tests/mochitest/fetch/fetch_test_framework.js @@ -47,40 +47,3 @@ function testScript(script) { }); } -// Utilities -// ========= - -// Helper that uses FileReader or FileReaderSync based on context and returns -// a Promise that resolves with the text or rejects with error. -function readAsText(blob) { - if (typeof FileReader !== "undefined") { - return new Promise(function(resolve, reject) { - var fs = new FileReader(); - fs.onload = function() { - resolve(fs.result); - } - fs.onerror = reject; - fs.readAsText(blob); - }); - } else { - var fs = new FileReaderSync(); - return Promise.resolve(fs.readAsText(blob)); - } -} - -function readAsArrayBuffer(blob) { - if (typeof FileReader !== "undefined") { - return new Promise(function(resolve, reject) { - var fs = new FileReader(); - fs.onload = function() { - resolve(fs.result); - } - fs.onerror = reject; - fs.readAsArrayBuffer(blob); - }); - } else { - var fs = new FileReaderSync(); - return Promise.resolve(fs.readAsArrayBuffer(blob)); - } -} - diff --git a/dom/tests/mochitest/fetch/mochitest.ini b/dom/tests/mochitest/fetch/mochitest.ini index cc380c2d68ab..b0a1fda21561 100644 --- a/dom/tests/mochitest/fetch/mochitest.ini +++ b/dom/tests/mochitest/fetch/mochitest.ini @@ -4,9 +4,11 @@ support-files = test_fetch_basic.js test_fetch_basic_http.js test_fetch_cors.js + test_formdataparsing.js test_headers_common.js test_request.js test_response.js + utils.js worker_wrapper.js [test_headers.html] @@ -14,5 +16,6 @@ support-files = [test_fetch_basic.html] [test_fetch_basic_http.html] [test_fetch_cors.html] +[test_formdataparsing.html] [test_request.html] [test_response.html] diff --git a/dom/tests/mochitest/fetch/test_fetch_basic.html b/dom/tests/mochitest/fetch/test_fetch_basic.html index 2655a0e833f1..ce7b63abae31 100644 --- a/dom/tests/mochitest/fetch/test_fetch_basic.html +++ b/dom/tests/mochitest/fetch/test_fetch_basic.html @@ -13,6 +13,7 @@


+
 
 
 
 
 
 
+  
+
+
+

+ +

+
+
+
+
+
+
diff --git a/dom/tests/mochitest/fetch/test_formdataparsing.js b/dom/tests/mochitest/fetch/test_formdataparsing.js
new file mode 100644
index 000000000000..bc7eb21a603a
--- /dev/null
+++ b/dom/tests/mochitest/fetch/test_formdataparsing.js
@@ -0,0 +1,283 @@
+var boundary = "1234567891011121314151617";
+
+// fn(body) should create a Body subclass with content body treated as
+// FormData and return it.
+function testFormDataParsing(fn) {
+
+  function makeTest(shouldPass, input, testFn) {
+    var obj = fn(input);
+    return obj.formData().then(function(fd) {
+      ok(shouldPass, "Expected test to be valid FormData for " + input);
+      if (testFn) {
+        return testFn(fd);
+      }
+    }, function(e) {
+      if (shouldPass) {
+        ok(false, "Expected test to pass for " + input);
+      } else {
+        ok(e.name == "TypeError", "Error should be a TypeError.");
+      }
+    });
+  }
+
+  // [shouldPass?, input, testFn]
+  var tests =
+    [
+      [ true,
+
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), '"hello"');
+        }
+      ],
+      [ false,
+
+        // Invalid disposition.
+        boundary +
+        '\r\nContent-Disposition: form-datafoobar; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+
+        '--' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), '"hello"');
+        }
+      ],
+      [ false,
+        boundary + "\r\n\r\n" + boundary + '-',
+      ],
+      [ false,
+        // No valid ending.
+        boundary + "\r\n\r\n" + boundary,
+      ],
+      [ false,
+
+        // One '-' prefix is not allowed. 2 or none.
+        '-' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        'invalid' +
+        boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary + 'suffix' +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary + 'suffix' +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        // Partial boundary
+        boundary.substr(3) +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Missing '\n' at beginning.
+        '\rContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No form-data.
+        '\r\nContent-Disposition: mixed; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No headers.
+        '\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No content-disposition.
+        '\r\nContent-Dispositypo: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // No name.
+        '\r\nContent-Disposition: form-data;\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Missing empty line between headers and body.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        // Empty entry followed by valid entry.
+        boundary + "\r\n\r\n" + boundary +
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n"hello"\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+
+        boundary +
+        // Header followed by empty line, but empty body not followed by
+        // newline.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+
+        boundary +
+        // Empty body followed by newline.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          is(fd.get("greeting"), "", "Empty value is allowed.");
+        }
+      ],
+      [ false,
+        boundary +
+        // Value is boundary itself.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary + '\r\n' +
+        boundary + '-',
+      ],
+      [ false,
+        boundary +
+        // Variant of above with no valid ending boundary.
+        '\r\nContent-Disposition: form-data; name="greeting"\r\n\r\n' +
+        boundary
+      ],
+      [ true,
+        boundary +
+        // Unquoted filename with empty body.
+        '\r\nContent-Disposition: form-data; name="file"; filename=file1.txt\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file1.txt", "Filename should match.");
+          is(f.type, "text/plain", "Default content-type should be text/plain.");
+          return readAsText(f).then(function(text) {
+            is(text, "", "File should be empty.");
+          });
+        }
+      ],
+      [ true,
+        boundary +
+        // Quoted filename with empty body.
+        '\r\nContent-Disposition: form-data; name="file"; filename="file1.txt"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file1.txt", "Filename should match.");
+          is(f.type, "text/plain", "Default content-type should be text/plain.");
+          return readAsText(f).then(function(text) {
+            is(text, "", "File should be empty.");
+          });
+        }
+      ],
+      [ false,
+        boundary +
+        // Invalid filename
+        '\r\nContent-Disposition: form-data; name="file"; filename="[\n@;xt"\r\n\r\n\r\n' +
+        boundary + '-',
+      ],
+      [ true,
+        boundary +
+        '\r\nContent-Disposition: form-data; name="file"; filename="[@;xt"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "[@", "Filename should match.");
+        }
+      ],
+      [ true,
+        boundary +
+        '\r\nContent-Disposition: form-data; name="file"; filename="file with   spaces"\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "file with spaces", "Filename should match.");
+        }
+      ],
+      [ true,
+        boundary + '\r\n' +
+        'Content-Disposition: form-data; name="file"; filename="xml.txt"\r\n' +
+        'content-type       : application/xml\r\n' +
+        '\r\n' +
+        'foobar\r\n\r\n\r\n' +
+        boundary + '-',
+
+        function(fd) {
+          var f = fd.get("file");
+          ok(f instanceof File, "Entry with filename attribute should be read as File.");
+          is(f.name, "xml.txt", "Filename should match.");
+          is(f.type, "application/xml", "content-type should be application/xml.");
+          return readAsText(f).then(function(text) {
+            is(text, "foobar\r\n\r\n", "File should have correct text.");
+          });
+        }
+      ],
+    ];
+
+  var promises = [];
+  for (var i = 0; i < tests.length; ++i) {
+    var test = tests[i];
+    promises.push(makeTest(test[0], test[1], test[2]));
+  }
+
+  return Promise.all(promises);
+}
+
+function makeRequest(body) {
+  var req = new Request("", { method: 'post', body: body,
+                              headers: {
+                                'Content-Type': 'multipart/form-data; boundary=' + boundary
+                              }});
+  return req;
+}
+
+function makeResponse(body) {
+  var res = new Response(body, { headers: {
+                                   'Content-Type': 'multipart/form-data; boundary=' + boundary
+                                 }});
+  return res;
+}
+
+function runTest() {
+  return Promise.all([testFormDataParsing(makeRequest),
+                      testFormDataParsing(makeResponse)]);
+}
diff --git a/dom/tests/mochitest/fetch/test_headers.html b/dom/tests/mochitest/fetch/test_headers.html
index d8b4713f7713..f13f53425d58 100644
--- a/dom/tests/mochitest/fetch/test_headers.html
+++ b/dom/tests/mochitest/fetch/test_headers.html
@@ -8,6 +8,7 @@
   
 
 
+