From a10338a6ee32b0342074a7ccbcf9a850ce47a7ed Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 16 Oct 2026 21:38:40 -0400 Subject: [PATCH] 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 \\. --- httplib.h | 77 ++++++++++++++++++++++++++++------------------------ test/test.cc | 41 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/httplib.h b/httplib.h index 8de6f87d..8cce7146 100644 --- a/httplib.h +++ b/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 @@ -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 ¶ms) { std::set 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; diff --git a/test/test.cc b/test/test.cc index 815c9674..ddfa0dd3 100644 --- a/test/test.cc +++ b/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.