Add filename sanitization function and tests to prevent path traversal vulnerabilities

This commit is contained in:
yhirose
2026-03-13 00:29:13 -04:00
parent 4d7c9a788d
commit 83e98a28dd
3 changed files with 59 additions and 4 deletions
+14 -4
View File
@@ -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
+26
View File
@@ -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 &params);
std::pair<std::string, std::string> 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 &params) {
std::string path_with_query = path;
+19
View File
@@ -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 = "-_.!~*'()";