Przeglądaj źródła

Unescape quoted Content-Type parameters and escape backslashes

A quoted boundary kept its quoted-pairs, as only the quotes were taken
off, and the multipart writer sent a backslash as it was, so a name
ending in one escaped the closing quote for this library's own parser.
Unescape \" and \\ in media type parameters as in Content-Disposition,
and write a backslash as \\.
yhirose 2 dni temu
rodzic
commit
a10338a6ee
2 zmienionych plików z 82 dodań i 36 usunięć
  1. 41 36
      httplib.h
  2. 41 0
      test/test.cc

+ 41 - 36
httplib.h

@@ -6185,6 +6185,22 @@ inline std::string trim_double_quotes_copy(const std::string &s) {
   return s;
 }
 
+// Takes the quotes off a parameter value. Only \" and \\ are quoted-pairs
+// here, as browsers send any other backslash, such as one in a Windows path,
+// as it is.
+inline std::string unquote_param(const std::string &s) {
+  if (s.size() < 2 || s.front() != '"' || s.back() != '"') { return s; }
+  std::string out;
+  for (size_t i = 1; i + 1 < s.size(); i++) {
+    if (s[i] == '\\' && i + 2 < s.size() &&
+        (s[i + 1] == '"' || s[i + 1] == '\\')) {
+      i++;
+    }
+    out += s[i];
+  }
+  return out;
+}
+
 inline void
 divide(const char *data, std::size_t size, char d,
        std::function<void(const char *, std::size_t, const char *, std::size_t)>
@@ -7742,7 +7758,7 @@ extract_media_type(const std::string &content_type,
                        // §5.6.6)
                        if (!key.empty()) {
                          params->emplace(case_ignore::to_lower(trim_copy(key)),
-                                         trim_double_quotes_copy(val));
+                                         unquote_param(val));
                        }
                      });
     }
@@ -9328,39 +9344,22 @@ inline bool parse_multipart_boundary(const std::string &content_type,
   return !boundary.empty() && boundary.size() <= 70;
 }
 
-// Takes the quotes off a parameter value. Only \" and \\ are quoted-pairs
-// here, as browsers send any other backslash, such as one in a Windows path,
-// as it is.
-inline std::string unquote_disposition_param(const std::string &s) {
-  if (s.size() < 2 || s.front() != '"' || s.back() != '"') { return s; }
-  std::string out;
-  for (size_t i = 1; i + 1 < s.size(); i++) {
-    if (s[i] == '\\' && i + 2 < s.size() &&
-        (s[i + 1] == '"' || s[i + 1] == '\\')) {
-      i++;
-    }
-    out += s[i];
-  }
-  return out;
-}
-
 inline void parse_disposition_params(const std::string &s, Params &params) {
   std::set<std::string> cache;
-  split_unquoted(s.data(), s.data() + s.size(), ';',
-                 [&](const char *b, const char *e) {
-                   std::string kv(b, e);
-                   if (cache.find(kv) != cache.end()) { return; }
-                   cache.insert(kv);
+  split_unquoted(
+      s.data(), s.data() + s.size(), ';', [&](const char *b, const char *e) {
+        std::string kv(b, e);
+        if (cache.find(kv) != cache.end()) { return; }
+        cache.insert(kv);
 
-                   std::string key;
-                   std::string val;
-                   divide_param_pair(b, e, key, val);
+        std::string key;
+        std::string val;
+        divide_param_pair(b, e, key, val);
 
-                   if (!key.empty()) {
-                     params.emplace(trim_double_quotes_copy(key),
-                                    unquote_disposition_param(val));
-                   }
-                 });
+        if (!key.empty()) {
+          params.emplace(trim_double_quotes_copy(key), unquote_param(val));
+        }
+      });
 }
 
 #ifdef CPPHTTPLIB_NO_EXCEPTIONS
@@ -9877,23 +9876,29 @@ inline bool is_multipart_boundary_chars_valid(const std::string &boundary) {
 }
 
 // Escape a multipart field name/filename following the WHATWG HTML standard
-// ("escape a multipart form-data name"), which is what browsers send:
-// '"' -> %22, CR -> %0D, LF -> %0A
-// With escape_quote = false, only CR and LF are escaped; this is for header
-// values outside a quoted-string (e.g. Content-Type), where '"' is legal.
+// ("escape a multipart form-data name"): '"' -> %22, CR -> %0D, LF -> %0A.
+// As the value is a quoted-string, '\' -> '\\' too, which browsers leave out.
+// With quoted = false, only CR and LF are escaped; this is for header values
+// outside a quoted-string (e.g. Content-Type), where '"' and '\' are legal.
 inline std::string escape_multipart_field(const std::string &s,
-                                          bool escape_quote = true) {
+                                          bool quoted = true) {
   std::string result;
   result.reserve(s.size());
   for (auto c : s) {
     switch (c) {
     case '"':
-      if (escape_quote) {
+      if (quoted) {
         result += "%22";
       } else {
         result += c;
       }
       break;
+    case '\\':
+      // A lone backslash would escape the character after it (RFC 9110
+      // 5.6.4).
+      if (quoted) { result += '\\'; }
+      result += c;
+      break;
     case '\r': result += "%0D"; break;
     case '\n': result += "%0A"; break;
     default: result += c; break;

+ 41 - 0
test/test.cc

@@ -1739,6 +1739,37 @@ TEST(MultipartOrderTest, RepeatedNamesKeepTheirOrder) {
   EXPECT_EQ("", out_of_range);
 }
 
+TEST(MultipartFormDataTest, BackslashesInNamesSurvive) {
+  Server svr;
+  std::string file_names;
+  svr.Post("/upload", [&](const Request &req, Response &res) {
+    for (const auto &file : req.form.files) {
+      file_names += file.first + "|" + file.second.filename + " ";
+    }
+    res.set_content("ok", "text/plain");
+  });
+
+  auto port = svr.bind_to_any_port(HOST);
+  thread t = thread([&] { svr.listen_after_bind(); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+  svr.wait_until_ready();
+
+  // A trailing backslash would otherwise escape the closing quote.
+  UploadFormDataItems items = {
+      {"dir\\", "x", "a.txt", "text/plain"},
+      {"b", "y", "a\\\\b.txt", "text/plain"},
+  };
+
+  Client cli(HOST, port);
+  auto res = cli.Post("/upload", items);
+  ASSERT_TRUE(res);
+  EXPECT_EQ("dir\\|a.txt b|a\\\\b.txt ", file_names);
+}
+
 TEST(MultipartOrderTest, ContentSurvivesContainerGrowth) {
   // Server::read_content() keeps a FormFields::iterator alive across the
   // content callbacks that fill the part it points at. The container is
@@ -1872,6 +1903,16 @@ TEST(ParseMultipartBoundaryTest, QuotedValueWithSemicolon) {
   EXPECT_EQ(parsed, "a;b");
 }
 
+TEST(ParseMultipartBoundaryTest, QuotedPairsAreUnescaped) {
+  string parsed;
+  EXPECT_TRUE(detail::parse_multipart_boundary(
+      "multipart/form-data; boundary=\"a\\\\b\"", parsed));
+  EXPECT_EQ("a\\b", parsed);
+  EXPECT_TRUE(detail::parse_multipart_boundary(
+      "multipart/form-data; boundary=\"a\\\"b\"", parsed));
+  EXPECT_EQ("a\"b", parsed);
+}
+
 TEST(ParseDispositionParamsTest, QuotedValues) {
   // RFC 9110 Section 5.6.6: a parameter value may be a quoted-string, and
   // ';' and '=' are ordinary characters inside one.