Skip to content
File

Blob: src/workerd/api/form-data.c++

17.0 KB
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5#include "form-data.h"
6 
7#include "util.h"
8 
9#include <workerd/io/io-util.h>
10#include <workerd/util/mimetype.h>
11#include <workerd/util/own-util.h>
12 
13#include <kj/compat/http.h>
14#include <kj/parse/char.h>
15#include <kj/vector.h>
16 
17#include <algorithm>
18#include <regex>
19 
20#if !_MSC_VER
21#include <strings.h>
22#endif
23 
24namespace workerd::api {
25 
26namespace {
27// Like split() in kj/compat/url.c++, but splits at a substring rather than a character.
28kj::ArrayPtr<const char> splitAtSubString(kj::ArrayPtr<const char>& text, kj::StringPtr subString) {
29 // TODO(perf): Use a Boyer-Moore search?
30 auto iter = std::search(text.begin(), text.end(), subString.begin(), subString.end());
31 auto result = kj::arrayPtr(text.begin(), iter - text.begin());
32 text =
33 text.slice(kj::min(text.size(), result.end() - text.begin() + subString.size()), text.size());
34 return result;
35}
36 
37struct FormDataHeaderTable {
38 kj::HttpHeaderId contentDispositionId;
39 kj::Own<kj::HttpHeaderTable> table;
40 
41 FormDataHeaderTable(kj::HttpHeaderTable::Builder builder)
42 : contentDispositionId(builder.add("Content-Disposition")),
43 table(builder.build()) {}
44};
45 
46const FormDataHeaderTable& getFormDataHeaderTable() {
47 static const FormDataHeaderTable table({});
48 return table;
49}
50 
51namespace p = kj::parse;
52constexpr auto httpIdentifier = p::oneOrMore(p::nameChar.orChar('-'));
53constexpr auto quotedChar = p::oneOf(p::anyOfChars("\"\n\\").invert(),
54 // Chrome interprets "\<c>" as reducing to <c> for any character <c>, including double quote.
55 // (So "\n" = "n", etc.)
56 p::sequence(p::exactChar<'\\'>(), p::anyOfChars("\n").invert()));
57constexpr auto contentDispositionParam = p::sequence(p::exactChar<';'>(),
58 p::discardWhitespace,
59 httpIdentifier,
60 p::discardWhitespace,
61 p::exactChar<'='>(),
62 p::discardWhitespace,
63 p::exactChar<'"'>(),
64 p::oneOrMore(quotedChar),
65 p::exactChar<'"'>(),
66 p::discardWhitespace);
67constexpr auto contentDisposition = p::sequence(
68 p::discardWhitespace, httpIdentifier, p::discardWhitespace, p::many(contentDispositionParam));
69 
70kj::OneOf<jsg::Ref<File>, kj::String> blobToFile(jsg::Lock& js,
71 kj::StringPtr name,
72 kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value,
73 jsg::Optional<kj::String> filename) {
74 auto fromBlob = [&](jsg::Ref<Blob> blob) {
75 kj::String fn;
76 KJ_IF_SOME(f, filename) {
77 fn = kj::mv(f);
78 } else {
79 fn = kj::str(name);
80 }
81 // The file is created with the same data as the blob (essentially as just
82 // a view of the same blob) to avoid copying the data.
83 return js.alloc<File>(
84 blob.addRef(), blob->getData(), kj::mv(fn), kj::str(blob->getType()), dateNow());
85 };
86 
87 KJ_SWITCH_ONEOF(value) {
88 KJ_CASE_ONEOF(file, jsg::Ref<File>) {
89 if (filename == kj::none) {
90 return kj::mv(file);
91 } else {
92 // Need to substitute filename.
93 return fromBlob(kj::mv(file));
94 }
95 }
96 KJ_CASE_ONEOF(blob, jsg::Ref<Blob>) {
97 return fromBlob(kj::mv(blob));
98 }
99 KJ_CASE_ONEOF(string, kj::String) {
100 return kj::mv(string);
101 }
102 }
103 KJ_UNREACHABLE;
104}
105 
106// Add the chars from `value` into `builder` escaping the characters '"' and '\n' using %
107// encoding, exactly as Chrome does for Content-Disposition values.
108void addEscapingQuotes(kj::Vector<char>& builder, kj::StringPtr value) {
109 // Chrome throws "Failed to fetch" if the name ends with a backslash. Otherwise it worries that
110 // the backslash may be interpreted as escaping the final quote.
111 JSG_REQUIRE(!value.endsWith("\\"), TypeError, "Name or filename can't end with backslash");
112 
113 for (char c: value) {
114 switch (c) {
115 case '\"':
116 // Firefox supposedly escapes this as '\"', but Chrome chooses to use percent escapes,
117 // probably for fear of a buggy receiver who interprets the '"' as being the end of the
118 // string. There is no standard.
119 builder.addAll("%22"_kj);
120 break;
121 case '\n':
122 builder.addAll("%0A"_kj);
123 break;
124 case '\\':
125 // Chrome doesn't escape '\', but this awkwardly means that the '\' will be evaluated as
126 // an escape sequence on the other end. That seems like a bug. Let's not copy bugs.
127 builder.addAll("\\\\"_kj);
128 break;
129 default:
130 builder.add(c);
131 break;
132 }
133 }
134}
135 
136void assertUtf8(const auto& params) {
137 KJ_IF_SOME(charset, params.find("charset"_kj)) {
138 JSG_REQUIRE(strcasecmp(charset.cStr(), "utf-8") == 0 ||
139 strcasecmp(charset.cStr(), "utf8") == 0 ||
140 strcasecmp(charset.cStr(), "unicode-1-1-utf-8") == 0,
141 TypeError, "Non-utf-8 application/x-www-form-urlencoded body.");
142 }
143}
144 
145} // namespace
146 
147// =======================================================================================
148// FormData implementation
149 
150void FormData::parseFormDataImpl(
151 kj::ArrayPtr<const char> rawText, kj::StringPtr boundary, ParseCallback callback) {
152 // multipart/form-data messages are delimited by <CRLF>--<boundary>. We want to be able to handle
153 // omitted carriage returns, though, so our delimiter only matches against a preceding line feed.
154 const auto delimiter = kj::str("\n--", boundary);
155 
156 // We want to slice off the delimiter's preceding newline for the initial search, because the very
157 // first instance does not require one. In every subsequent multipart message, the preceding
158 // newline is required.
159 auto message = splitAtSubString(rawText, delimiter.slice(1));
160 
161 JSG_REQUIRE(rawText.size() > 0, TypeError,
162 "No initial boundary string (or you have a truncated message).");
163 
164 static const auto done = [](kj::ArrayPtr<const char>& body) {
165 // Consume any (CR)LF characters that trailed the boundary and indicate continuation, or consume
166 // the terminal "--" characters and indicate termination, or throw an error.
167 if (body.startsWith("\n"_kj)) {
168 body = body.slice(1, body.size());
169 } else if (body.startsWith("\r\n"_kj)) {
170 body = body.slice(2, body.size());
171 } else if (body.startsWith("--"_kj)) {
172 // We're done!
173 return true;
174 } else {
175 JSG_FAIL_REQUIRE(TypeError, "Boundary string was not succeeded by CRLF, LF, or '--'.");
176 }
177 return false;
178 };
179 
180 constexpr auto staticRegexFlags =
181 std::regex_constants::ECMAScript | std::regex_constants::optimize;
182 
183 static const auto headerTerminationRegex = std::regex("\r?\n\r?\n", staticRegexFlags);
184 
185 std::cmatch match;
186 
187 auto& formDataHeaderTable = getFormDataHeaderTable();
188 
189 while (!done(rawText)) {
190 JSG_REQUIRE(std::regex_search(rawText.begin(), rawText.end(), match, headerTerminationRegex),
191 TypeError, "No multipart message header termination found.");
192 
193 // TODO(cleanup): Use kj-http to parse multipart headers. Right now that API isn't public, so
194 // I'm just using a regex. For reference, multipart/form-data supports the following three
195 // headers (https://tools.ietf.org/html/rfc7578#section-4.8):
196 //
197 // Content-Disposition (required)
198 // Content-Type (optional, recommended for files)
199 // Content-Transfer-Encoding (for 7-bit encoding, deprecated in HTTP contexts)
200 //
201 // TODO(soon): Read the Content-Type to support files.
202 
203 auto headersText = kj::str(rawText.first(match[0].second - rawText.begin()));
204 rawText = rawText.slice(match[0].second - rawText.begin(), rawText.size());
205 
206 kj::HttpHeaders headers(*formDataHeaderTable.table);
207 JSG_REQUIRE(headers.tryParse(headersText), TypeError, "FormData part had invalid headers.");
208 
209 kj::StringPtr disposition =
210 JSG_REQUIRE_NONNULL(headers.get(formDataHeaderTable.contentDispositionId), TypeError,
211 "No valid Content-Disposition header found in FormData part.");
212 
213 kj::Maybe<kj::String> maybeName;
214 kj::Maybe<kj::String> filename;
215 {
216 p::IteratorInput<char, const char*> input(disposition.begin(), disposition.end());
217 auto result = JSG_REQUIRE_NONNULL(contentDisposition(input), TypeError,
218 "Invalid Content-Disposition header found in FormData part.");
219 JSG_REQUIRE(kj::get<0>(result) == "form-data"_kj.asArray(), TypeError,
220 "Content-Disposition header for FormData part must have the value \"form-data\", "
221 "possibly followed by parameters. Got: \"",
222 kj::get<0>(result), "\"");
223 
224 for (auto& param: kj::get<1>(result)) {
225 if (kj::get<0>(param) == "name"_kj.asArray()) {
226 maybeName = kj::str(kj::get<1>(param));
227 } else if (kj::get<0>(param) == "filename"_kj.asArray()) {
228 filename = kj::str(kj::get<1>(param));
229 }
230 }
231 }
232 
233 kj::String name = JSG_REQUIRE_NONNULL(kj::mv(maybeName), TypeError,
234 "Content-Disposition header in FormData part is missing a name.");
235 
236 kj::Maybe<kj::StringPtr> type = headers.get(kj::HttpHeaderId::CONTENT_TYPE);
237 
238 message = splitAtSubString(rawText, delimiter);
239 JSG_REQUIRE(
240 rawText.size() > 0, TypeError, "No subsequent boundary string after multipart message.");
241 
242 if (message.size() > 0) {
243 // If we skipped a CR, we must avoid including it in the message data.
244 message = message.first(message.size() - static_cast<uint>(message.back() == '\r'));
245 }
246 
247 callback(name, filename.map([](auto& str) { return str.asPtr(); }), type, message.asBytes());
248 }
249}
250 
251void FormData::parse(jsg::Lock& js,
252 kj::ArrayPtr<const char> rawText,
253 kj::StringPtr contentType,
254 bool convertFilesToStrings) {
255 KJ_IF_SOME(parsed, MimeType::tryParse(contentType)) {
256 auto& params = parsed.params();
257 if (MimeType::FORM_DATA == parsed) {
258 auto& boundary = JSG_REQUIRE_NONNULL(params.find("boundary"_kj), TypeError,
259 "No boundary string in Content-Type header. The multipart/form-data MIME "
260 "type requires a boundary parameter, e.g. 'Content-Type: multipart/form-data; "
261 "boundary=\"abcd\"'. See RFC 7578, section 4.");
262 
263 parseFormDataImpl(rawText, boundary,
264 [&](kj::StringPtr name, kj::Maybe<kj::StringPtr> maybeFilename,
265 kj::Maybe<kj::StringPtr> maybeType, kj::ArrayPtr<const kj::byte> message) {
266 KJ_IF_SOME(filename, maybeFilename) {
267 if (convertFilesToStrings) {
268 auto messageData = kj::heapArray<char>(message.asChars());
269 data.add(FormData::Entry{
270 .name = kj::str(name),
271 .value = kj::str(kj::mv(messageData)),
272 });
273 } else {
274 auto bytes = jsg::JsArrayBuffer::create(js, message);
275 data.add(FormData::Entry{.name = kj::str(name),
276 .value = js.alloc<File>(js, jsg::JsBufferSource(bytes), kj::str(filename),
277 kj::str(maybeType.orDefault(nullptr)), dateNow())});
278 }
279 } else {
280 auto messageData = kj::heapArray<char>(message.asChars());
281 data.add(FormData::Entry{
282 .name = kj::str(name),
283 .value = kj::str(kj::mv(messageData)),
284 });
285 }
286 });
287 return;
288 } else if (MimeType::FORM_URLENCODED == parsed) {
289 // Let's read the charset so we can barf if the body isn't UTF-8.
290 //
291 // TODO(conform): Transcode to UTF-8, like the spec tells us to.
292 assertUtf8(params);
293 kj::Vector<kj::Url::QueryParam> query;
294 parseQueryString(query, kj::mv(rawText));
295 data.reserve(query.size());
296 for (auto& param: query) {
297 data.add(Entry{
298 .name = kj::str(param.name),
299 .value = kj::str(param.value),
300 });
301 }
302 return;
303 }
304 }
305 JSG_FAIL_REQUIRE(TypeError,
306 "Unrecognized Content-Type header value. FormData can only "
307 "parse the following MIME types: ",
308 MimeType::FORM_DATA.toString(), ", ", MimeType::FORM_URLENCODED.toString());
309}
310 
311kj::Array<kj::byte> FormData::serialize(kj::ArrayPtr<const char> boundary) {
312 // Boundary string requirement per RFC7578
313 JSG_REQUIRE(boundary.size() > 0 && boundary.size() <= 70, TypeError,
314 "Length of multipart/form-data boundary string must be in the range [1, 70].");
315 
316 // TODO(perf): We should be able to trivially calculate the length of the serialized form data
317 // beforehand. I tried, but apparently my math REALLY sucks and I hate memory overruns, so ...
318 auto builder = kj::Vector<char>{};
319 
320 for (auto& kv: data) {
321 builder.addAll("--"_kj);
322 builder.addAll(boundary);
323 builder.addAll("\r\n"_kj);
324 builder.addAll("Content-Disposition: form-data; name=\""_kj);
325 addEscapingQuotes(builder, kv.name);
326 KJ_SWITCH_ONEOF(kv.value) {
327 KJ_CASE_ONEOF(text, kj::String) {
328 builder.addAll("\"\r\n\r\n"_kj);
329 builder.addAll(text);
330 }
331 KJ_CASE_ONEOF(file, jsg::Ref<File>) {
332 builder.addAll("\"; filename=\""_kj);
333 addEscapingQuotes(builder, file->getName());
334 builder.addAll("\"\r\nContent-Type: "_kj);
335 auto type = file->getType();
336 if (type == nullptr) {
337 builder.addAll(MimeType::OCTET_STREAM.toString());
338 } else {
339 builder.addAll(type);
340 }
341 builder.addAll("\r\n\r\n"_kj);
342 builder.addAll(file->getData().asChars());
343 }
344 }
345 builder.addAll("\r\n"_kj);
346 }
347 builder.addAll("--"_kj);
348 builder.addAll(boundary);
349 builder.addAll("--"_kj);
350 
351 return builder.releaseAsArray().releaseAsBytes();
352}
353 
354FormData::EntryType FormData::clone(jsg::Lock& js, FormData::EntryType& value) {
355 KJ_SWITCH_ONEOF(value) {
356 KJ_CASE_ONEOF(file, jsg::Ref<File>) {
357 return file.addRef();
358 }
359 KJ_CASE_ONEOF(string, kj::String) {
360 auto data = kj::heapArray<char>(string);
361 return kj::str(kj::mv(data));
362 }
363 }
364 KJ_UNREACHABLE;
365}
366 
367jsg::Ref<FormData> FormData::constructor(jsg::Lock& js) {
368 return js.alloc<FormData>();
369}
370 
371void FormData::append(jsg::Lock& js,
372 kj::String name,
373 kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value,
374 jsg::Optional<kj::String> filename) {
375 auto filifiedValue = blobToFile(js, name, kj::mv(value), kj::mv(filename));
376 data.add(Entry{kj::mv(name), kj::mv(filifiedValue)});
377}
378 
379void FormData::delete_(kj::String name) {
380 auto pivot =
381 std::remove_if(data.begin(), data.end(), [&name](const auto& kv) { return kv.name == name; });
382 data.truncate(pivot - data.begin());
383}
384 
385kj::Maybe<kj::OneOf<jsg::Ref<File>, kj::String>> FormData::get(jsg::Lock& js, kj::String name) {
386 for (auto& [k, v]: data) {
387 if (k == name) {
388 return clone(js, v);
389 }
390 }
391 return kj::none;
392}
393 
394kj::Array<kj::OneOf<jsg::Ref<File>, kj::String>> FormData::getAll(jsg::Lock& js, kj::String name) {
395 kj::Vector<kj::OneOf<jsg::Ref<File>, kj::String>> result;
396 for (auto& [k, v]: data) {
397 if (k == name) {
398 result.add(clone(js, v));
399 }
400 }
401 return result.releaseAsArray();
402}
403 
404bool FormData::has(kj::String name) {
405 for (auto& [k, v]: data) {
406 if (k == name) {
407 return true;
408 }
409 }
410 return false;
411}
412 
413// Set the first element named `name` to `value`, then remove all the rest matching that name.
414void FormData::set(jsg::Lock& js,
415 kj::String name,
416 kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value,
417 jsg::Optional<kj::String> filename) {
418 const auto predicate = [name = name.slice(0)](const auto& kv) { return kv.name == name; };
419 auto firstFound = std::find_if(data.begin(), data.end(), predicate);
420 if (firstFound != data.end()) {
421 firstFound->value = blobToFile(js, name, kj::mv(value), kj::mv(filename));
422 auto pivot = std::remove_if(++firstFound, data.end(), predicate);
423 data.truncate(pivot - data.begin());
424 } else {
425 append(js, kj::mv(name), kj::mv(value), kj::mv(filename));
426 }
427}
428 
429jsg::Ref<FormData::EntryIterator> FormData::entries(jsg::Lock& js) {
430 return js.alloc<EntryIterator>(IteratorState{JSG_THIS});
431}
432 
433jsg::Ref<FormData::KeyIterator> FormData::keys(jsg::Lock& js) {
434 return js.alloc<KeyIterator>(IteratorState{JSG_THIS});
435}
436 
437jsg::Ref<FormData::ValueIterator> FormData::values(jsg::Lock& js) {
438 return js.alloc<ValueIterator>(IteratorState{JSG_THIS});
439}
440 
441void FormData::forEach(jsg::Lock& js,
442 jsg::Function<void(EntryType, kj::StringPtr, jsg::Ref<FormData>)> callback,
443 jsg::Optional<jsg::Value> thisArg) {
444 // Here, if the thisArg is not passed, or is passed explicitly as a null or
445 // undefined, then undefined is used as the thisArg.
446 auto receiver = js.v8Undefined();
447 KJ_IF_SOME(arg, thisArg) {
448 auto handle = arg.getHandle(js);
449 if (!handle->IsNullOrUndefined()) {
450 receiver = handle;
451 }
452 }
453 callback.setReceiver(js.v8Ref(receiver));
454 
455 // On each iteration of the for loop, a JavaScript callback is invoked. If a new
456 // item is appended to the URLSearchParams within that function, the loop must pick
457 // it up. Using the classic for (;;) syntax here allows for that. However, this does
458 // mean that it's possible for a user to trigger an infinite loop here if new items
459 // are added to the search params unconditionally on each iteration.
460 // Silence clang-tidy warning, using an iterator would not work correctly if callback
461 // increases the array size.
462 // NOLINTNEXTLINE(modernize-loop-convert)
463 for (size_t i = 0; i < this->data.size(); i++) {
464 auto& [key, value] = this->data[i];
465 callback(js, clone(js, value), key, JSG_THIS);
466 }
467}
468 
469} // namespace workerd::api