diff --git a/httplib.h b/httplib.h index 590aa67..c69421e 100644 --- a/httplib.h +++ b/httplib.h @@ -2428,6 +2428,13 @@ inline std::string trim_copy(const std::string &s) { return s.substr(r.first, r.second - r.first); } +inline std::string trim_double_quotes_copy(const std::string &s) { + if (s.length() >= 2 && s.front() == '"' && s.back() == '"') { + return s.substr(1, s.size() - 2); + } + return s; +} + inline void split(const char *b, const char *e, char d, std::function fn) { size_t i = 0; @@ -4064,14 +4071,34 @@ inline bool parse_multipart_boundary(const std::string &content_type, if (pos == std::string::npos) { return false; } auto end = content_type.find(';', pos); auto beg = pos + strlen(boundary_keyword); - boundary = content_type.substr(beg, end - beg); - if (boundary.length() >= 2 && boundary.front() == '"' && - boundary.back() == '"') { - boundary = boundary.substr(1, boundary.size() - 2); - } + boundary = trim_double_quotes_copy(content_type.substr(beg, end - beg)); return !boundary.empty(); } +inline void parse_disposition_params(const std::string &s, Params ¶ms) { + std::set cache; + split(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; + split(b, e, '=', [&](const char *b2, const char *e2) { + if (key.empty()) { + key.assign(b2, e2); + } else { + val.assign(b2, e2); + } + }); + + if (!key.empty()) { + params.emplace(trim_double_quotes_copy((key)), + trim_double_quotes_copy((val))); + } + }); +} + #ifdef CPPHTTPLIB_NO_EXCEPTIONS inline bool parse_range_header(const std::string &s, Ranges &ranges) { #else @@ -4129,11 +4156,6 @@ public: bool parse(const char *buf, size_t n, const ContentReceiver &content_callback, const MultipartContentHeader &header_callback) { - // TODO: support 'filename*' - static const std::regex re_content_disposition( - R"~(^Content-Disposition:\s*form-data;\s*name="(.*?)"(?:;\s*filename="(.*?)")?(?:;\s*filename\*=\S+)?\s*$)~", - std::regex_constants::icase); - buf_append(buf, n); while (buf_size() > 0) { @@ -4171,10 +4193,40 @@ public: if (start_with_case_ignore(header, header_name)) { file_.content_type = trim_copy(header.substr(header_name.size())); } else { + static const std::regex re_content_disposition( + R"~(^Content-Disposition:\s*form-data;\s*(.*)$)~", + std::regex_constants::icase); + std::smatch m; if (std::regex_match(header, m, re_content_disposition)) { - file_.name = m[1]; - file_.filename = m[2]; + Params params; + parse_disposition_params(m[1], params); + + auto it = params.find("name"); + if (it != params.end()) { + file_.name = it->second; + } else { + is_valid_ = false; + return false; + } + + it = params.find("filename"); + if (it != params.end()) { file_.filename = it->second; } + + it = params.find("filename*"); + if (it != params.end()) { + // Only allow UTF-8 enconnding... + static const std::regex re_rfc5987_encoding( + R"~(^UTF-8''(.+?)$)~", std::regex_constants::icase); + + std::smatch m2; + if (std::regex_match(it->second, m2, re_rfc5987_encoding)) { + file_.filename = decode_url(m2[1], false); // override... + } else { + is_valid_ = false; + return false; + } + } } else { is_valid_ = false; return false; diff --git a/test/test.cc b/test/test.cc index 93d2bdb..e56e35a 100644 --- a/test/test.cc +++ b/test/test.cc @@ -6105,6 +6105,69 @@ TEST(MultipartFormDataTest, PutInvalidBoundaryChars) { } } +TEST(MultipartFormDataTest, AlternateFilename) { + Server svr; + svr.Post("/test", [&](const Request &req, Response &res) { + ASSERT_EQ(3u, req.files.size()); + + auto it = req.files.begin(); + ASSERT_EQ("file1", it->second.name); + ASSERT_EQ("A.txt", it->second.filename); + ASSERT_EQ("text/plain", it->second.content_type); + ASSERT_EQ("Content of a.txt.\r\n", it->second.content); + + ++it; + ASSERT_EQ("file2", it->second.name); + ASSERT_EQ("a.html", it->second.filename); + ASSERT_EQ("text/html", it->second.content_type); + ASSERT_EQ("Content of a.html.\r\n", + it->second.content); + + ++it; + ASSERT_EQ("text", it->second.name); + ASSERT_EQ("", it->second.filename); + ASSERT_EQ("", it->second.content_type); + ASSERT_EQ("text default", it->second.content); + + res.set_content("ok", "text/plain"); + }); + + thread t = thread([&] { svr.listen(HOST, PORT); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + auto req = "POST /test HTTP/1.1\r\n" + "Content-Type: multipart/form-data;boundary=--------\r\n" + "Content-Length: 399\r\n" + "\r\n" + "----------\r\n" + "Content-Disposition: form-data; name=\"text\"\r\n" + "\r\n" + "text default\r\n" + "----------\r\n" + "Content-Disposition: form-data; filename*=\"UTF-8''\%41.txt\"; " + "filename=\"a.txt\"; name=\"file1\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Content of a.txt.\r\n" + "\r\n" + "----------\r\n" + "Content-Disposition: form-data; name=\"file2\" ;filename = " + "\"a.html\"\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "Content of a.html.\r\n" + "\r\n" + "------------\r\n"; + + ASSERT_TRUE(send_request(1, req)); +} + #endif #ifndef _WIN32