From 83e98a28dd36ed337a31fbfd76b19d8e114b5e0e Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 13 Mar 2026 00:29:13 -0400 Subject: [PATCH] Add filename sanitization function and tests to prevent path traversal vulnerabilities --- README.md | 18 ++++++++++++++---- httplib.h | 26 ++++++++++++++++++++++++++ test/test.cc | 19 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 31934dc..2986716 100644 --- a/README.md +++ b/README.md @@ -541,16 +541,16 @@ svr.Post("/multipart", [&](const Request& req, Response& res) { } // IMPORTANT: file.filename is an untrusted value from the client. - // Always extract only the basename to prevent path traversal attacks. - auto safe_name = std::filesystem::path(file.filename).filename(); - if (safe_name.empty() || safe_name == "." || safe_name == "..") { + // Always sanitize to prevent path traversal attacks. + auto safe_name = httplib::sanitize_filename(file.filename); + if (safe_name.empty()) { res.status = StatusCode::BadRequest_400; res.set_content("Invalid filename", "text/plain"); return; } // Save to disk - std::ofstream ofs(upload_dir / safe_name, std::ios::binary); + std::ofstream ofs(upload_dir + "/" + safe_name, std::ios::binary); ofs << file.content; } @@ -586,6 +586,16 @@ svr.Post("/multipart", [&](const Request& req, Response& res) { }); ``` +#### Filename Sanitization + +`file.filename` in multipart uploads is an untrusted value from the client. Always sanitize before using it in file paths: + +```cpp +auto safe = httplib::sanitize_filename(file.filename); +``` + +This function strips path separators (`/`, `\`), null bytes, leading/trailing whitespace, and rejects `.` and `..`. Returns an empty string if the filename is unsafe. + ### Receive content with a content receiver ```cpp diff --git a/httplib.h b/httplib.h index c1cc96c..d417d4b 100644 --- a/httplib.h +++ b/httplib.h @@ -2915,6 +2915,8 @@ std::string encode_query_component(const std::string &component, std::string decode_query_component(const std::string &component, bool plus_as_space = true); +std::string sanitize_filename(const std::string &filename); + std::string append_query_params(const std::string &path, const Params ¶ms); std::pair make_range_header(const Ranges &ranges); @@ -9395,6 +9397,30 @@ inline std::string decode_query_component(const std::string &component, return result; } +inline std::string sanitize_filename(const std::string &filename) { + // Extract basename: find the last path separator (/ or \) + auto pos = filename.find_last_of("/\\"); + auto result = + (pos != std::string::npos) ? filename.substr(pos + 1) : filename; + + // Strip null bytes + result.erase(std::remove(result.begin(), result.end(), '\0'), result.end()); + + // Trim whitespace + { + auto start = result.find_first_not_of(" \t"); + auto end = result.find_last_not_of(" \t"); + result = (start == std::string::npos) + ? "" + : result.substr(start, end - start + 1); + } + + // Reject . and .. + if (result == "." || result == "..") { return ""; } + + return result; +} + inline std::string append_query_params(const std::string &path, const Params ¶ms) { std::string path_with_query = path; diff --git a/test/test.cc b/test/test.cc index b001058..e27ba97 100644 --- a/test/test.cc +++ b/test/test.cc @@ -413,6 +413,25 @@ TEST(DecodePathTest, PercentCharacterNUL) { EXPECT_EQ(decode_path_component("x%00x"), expected); } +TEST(SanitizeFilenameTest, VariousPatterns) { + // Path traversal + EXPECT_EQ("passwd", httplib::sanitize_filename("../../../etc/passwd")); + EXPECT_EQ("passwd", httplib::sanitize_filename("..\\..\\etc\\passwd")); + EXPECT_EQ("file.txt", httplib::sanitize_filename("path/to\\..\\file.txt")); + // Normal and edge cases + EXPECT_EQ("photo.jpg", httplib::sanitize_filename("photo.jpg")); + EXPECT_EQ("filename.txt", + httplib::sanitize_filename("/path/to/filename.txt")); + EXPECT_EQ(".gitignore", httplib::sanitize_filename(".gitignore")); + EXPECT_EQ("", httplib::sanitize_filename("..")); + EXPECT_EQ("", httplib::sanitize_filename("")); + // Null bytes stripped + EXPECT_EQ("safe.txt", + httplib::sanitize_filename(std::string("safe\0.txt", 9))); + // Whitespace-only rejected + EXPECT_EQ("", httplib::sanitize_filename(" ")); +} + TEST(EncodeQueryParamTest, ParseUnescapedChararactersTest) { string unescapedCharacters = "-_.!~*'()";