Skip to content

Commit ca47321

Browse files
committed
Fix 9 security vulnerabilities found in audit
- Fix path traversal in file uploads: sanitize filenames to basename, reject ".." and "." when generate_random_filename_on_upload is off - Fix TOCTOU race in file_response: replace stat-then-open with open-then-fstat; add O_NOFOLLOW on non-Windows - Fix FD leaks in file_response: close fd on lseek failure and zero-size file path - Fix NULL deref in answer_to_connection: check conninfo before use - Fix uninitialized _file_size in file_info (default to 0) - Fix auth skip path bypass: normalize path (resolve ".." segments) before comparing against skip paths - Fix free() vs MHD_free() for digest auth username - Fix unchecked write error during file upload
1 parent 755ecc1 commit ca47321

File tree

8 files changed

+194
-15
lines changed

8 files changed

+194
-15
lines changed

src/file_response.cpp

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,29 @@ struct MHD_Response;
3232
namespace httpserver {
3333

3434
MHD_Response* file_response::get_raw_response() {
35-
struct stat sb;
35+
#ifndef _WIN32
36+
int fd = open(filename.c_str(), O_RDONLY | O_NOFOLLOW);
37+
#else
38+
int fd = open(filename.c_str(), O_RDONLY);
39+
#endif
40+
if (fd == -1) return nullptr;
3641

37-
// Deny everything but regular files
38-
if (stat(filename.c_str(), &sb) == 0) {
39-
if (!S_ISREG(sb.st_mode)) return nullptr;
40-
} else {
42+
struct stat sb;
43+
if (fstat(fd, &sb) != 0 || !S_ISREG(sb.st_mode)) {
44+
close(fd);
4145
return nullptr;
4246
}
4347

44-
int fd = open(filename.c_str(), O_RDONLY);
45-
if (fd == -1) return nullptr;
46-
4748
off_t size = lseek(fd, 0, SEEK_END);
48-
if (size == (off_t) -1) return nullptr;
49+
if (size == (off_t) -1) {
50+
close(fd);
51+
return nullptr;
52+
}
4953

5054
if (size) {
5155
return MHD_create_response_from_fd(size, fd);
5256
} else {
57+
close(fd);
5358
return MHD_create_response_from_buffer(0, nullptr, MHD_RESPMEM_PERSISTENT);
5459
}
5560
}

src/http_request.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ std::string_view http_request::get_digested_user() const {
351351
cache->digested_user = EMPTY;
352352
if (digested_user_c != nullptr) {
353353
cache->digested_user = digested_user_c;
354-
free(digested_user_c);
354+
MHD_free(digested_user_c);
355355
}
356356

357357
return cache->digested_user;

src/http_utils.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ const std::string http_utils::generate_random_upload_filename(const std::string&
271271
return ret_filename;
272272
}
273273

274+
std::string http_utils::sanitize_upload_filename(const std::string& filename) {
275+
if (filename.empty()) return "";
276+
277+
// Find the basename: take everything after the last '/' or '\'
278+
std::string::size_type pos = filename.find_last_of("/\\");
279+
std::string basename = (pos != std::string::npos) ? filename.substr(pos + 1) : filename;
280+
281+
// Reject empty basename, ".", and ".."
282+
if (basename.empty() || basename == "." || basename == "..") {
283+
return "";
284+
}
285+
286+
return basename;
287+
}
288+
274289
std::string get_ip_str(const struct sockaddr *sa) {
275290
if (!sa) throw std::invalid_argument("socket pointer is null");
276291

src/httpserver/file_info.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class file_info {
4242
file_info() = default;
4343

4444
private:
45-
size_t _file_size;
45+
size_t _file_size = 0;
4646
std::string _file_system_file_name;
4747
std::string _content_type;
4848
std::string _transfer_encoding;

src/httpserver/http_utils.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ class http_utils {
272272
static std::string standardize_url(const std::string&);
273273

274274
static const std::string generate_random_upload_filename(const std::string& directory);
275+
276+
static std::string sanitize_upload_filename(const std::string& filename);
275277
};
276278

277279
#define COMPARATOR(x, y, op) { \

src/webserver.cpp

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,11 @@ MHD_Result webserver::post_iterator(void *cls, enum MHD_ValueKind kind,
699699
if (mr->ws->generate_random_filename_on_upload) {
700700
file.set_file_system_file_name(http_utils::generate_random_upload_filename(mr->ws->file_upload_dir));
701701
} else {
702-
file.set_file_system_file_name(mr->ws->file_upload_dir + "/" + std::string(filename));
702+
std::string safe_name = http_utils::sanitize_upload_filename(filename);
703+
if (safe_name.empty()) {
704+
return MHD_NO;
705+
}
706+
file.set_file_system_file_name(mr->ws->file_upload_dir + "/" + safe_name);
703707
}
704708
// to not append to an already existing file, delete an already existing file
705709
unlink(file.get_file_system_file_name().c_str());
@@ -731,6 +735,9 @@ MHD_Result webserver::post_iterator(void *cls, enum MHD_ValueKind kind,
731735

732736
if (size > 0) {
733737
mr->upload_ostrm->write(data, size);
738+
if (!mr->upload_ostrm->good()) {
739+
return MHD_NO;
740+
}
734741
}
735742

736743
// update the file size in the map
@@ -774,13 +781,40 @@ std::shared_ptr<http_response> webserver::internal_error_page(details::modded_re
774781
}
775782

776783
bool webserver::should_skip_auth(const std::string& path) const {
784+
// Normalize path: resolve ".." and "." segments to prevent bypass
785+
std::string normalized;
786+
{
787+
std::vector<std::string> segments;
788+
std::string::size_type start = 0;
789+
// Skip leading slash
790+
if (!path.empty() && path[0] == '/') {
791+
start = 1;
792+
}
793+
while (start < path.size()) {
794+
auto end = path.find('/', start);
795+
if (end == std::string::npos) end = path.size();
796+
std::string seg = path.substr(start, end - start);
797+
if (seg == "..") {
798+
if (!segments.empty()) segments.pop_back();
799+
} else if (!seg.empty() && seg != ".") {
800+
segments.push_back(seg);
801+
}
802+
start = end + 1;
803+
}
804+
normalized = "/";
805+
for (size_t i = 0; i < segments.size(); i++) {
806+
if (i > 0) normalized += "/";
807+
normalized += segments[i];
808+
}
809+
}
810+
777811
for (const auto& skip_path : auth_skip_paths) {
778-
if (skip_path == path) return true;
812+
if (skip_path == normalized) return true;
779813
// Support wildcard suffix (e.g., "/public/*")
780814
if (skip_path.size() > 2 && skip_path.back() == '*' &&
781815
skip_path[skip_path.size() - 2] == '/') {
782816
std::string prefix = skip_path.substr(0, skip_path.size() - 1);
783-
if (path.compare(0, prefix.size(), prefix) == 0) return true;
817+
if (normalized.compare(0, prefix.size(), prefix) == 0) return true;
784818
}
785819
}
786820
return false;
@@ -1028,7 +1062,7 @@ MHD_Result webserver::answer_to_connection(void* cls, MHD_Connection* connection
10281062

10291063
const MHD_ConnectionInfo * conninfo = MHD_get_connection_info(connection, MHD_CONNECTION_INFO_CONNECTION_FD);
10301064

1031-
if (static_cast<webserver*>(cls)->tcp_nodelay) {
1065+
if (conninfo != nullptr && static_cast<webserver*>(cls)->tcp_nodelay) {
10321066
int yes = 1;
10331067
setsockopt(conninfo->connect_fd, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast<char*>(&yes), sizeof(int));
10341068
}

test/integ/authentication.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,39 @@ LT_BEGIN_AUTO_TEST(authentication_suite, auth_empty_skip_paths)
10901090
ws.stop();
10911091
LT_END_AUTO_TEST(auth_empty_skip_paths)
10921092

1093+
// Test that path traversal cannot bypass auth skip paths
1094+
// Requesting /public/../protected should NOT skip auth
1095+
LT_BEGIN_AUTO_TEST(authentication_suite, auth_skip_path_traversal_bypass)
1096+
webserver ws = create_webserver(PORT)
1097+
.auth_handler(centralized_auth_handler)
1098+
.auth_skip_paths({"/public/*"});
1099+
1100+
simple_resource sr;
1101+
LT_ASSERT_EQ(true, ws.register_resource("protected", &sr));
1102+
LT_ASSERT_EQ(true, ws.register_resource("public/info", &sr));
1103+
ws.start(false);
1104+
1105+
curl_global_init(CURL_GLOBAL_ALL);
1106+
std::string s;
1107+
CURL *curl;
1108+
CURLcode res;
1109+
long http_code = 0; // NOLINT(runtime/int)
1110+
1111+
// /public/../protected should normalize to /protected, which requires auth
1112+
curl = curl_easy_init();
1113+
curl_easy_setopt(curl, CURLOPT_URL, "localhost:" PORT_STRING "/public/../protected");
1114+
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
1115+
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
1116+
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
1117+
res = curl_easy_perform(curl);
1118+
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
1119+
LT_ASSERT_EQ(res, 0);
1120+
LT_CHECK_EQ(http_code, 401); // Should require auth, not be skipped
1121+
curl_easy_cleanup(curl);
1122+
1123+
ws.stop();
1124+
LT_END_AUTO_TEST(auth_skip_path_traversal_bypass)
1125+
10931126
LT_BEGIN_AUTO_TEST_ENV()
10941127
AUTORUN_TESTS()
10951128
LT_END_AUTO_TEST_ENV()

test/integ/file_upload.cpp

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,96 @@ LT_BEGIN_AUTO_TEST(file_upload_suite, file_upload_with_content_type)
964964
ws->stop();
965965
LT_END_AUTO_TEST(file_upload_with_content_type)
966966

967+
// Send file with a crafted filename for path traversal testing
968+
static std::pair<CURLcode, int32_t> send_file_with_traversal_name(int port, const char* crafted_filename) {
969+
curl_global_init(CURL_GLOBAL_ALL);
970+
971+
CURL *curl = curl_easy_init();
972+
973+
curl_mime *form = curl_mime_init(curl);
974+
curl_mimepart *field = curl_mime_addpart(form);
975+
curl_mime_name(field, TEST_KEY);
976+
// Use the real file for data, but override the filename
977+
curl_mime_filedata(field, TEST_CONTENT_FILEPATH);
978+
curl_mime_filename(field, crafted_filename);
979+
980+
CURLcode res;
981+
std::string url = "localhost:" + std::to_string(port) + "/upload";
982+
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
983+
curl_easy_setopt(curl, CURLOPT_MIMEPOST, form);
984+
985+
res = curl_easy_perform(curl);
986+
long http_code = 0; // NOLINT [runtime/int]
987+
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
988+
989+
curl_easy_cleanup(curl);
990+
curl_mime_free(form);
991+
return {res, http_code};
992+
}
993+
994+
// Test that path traversal filenames are rejected
995+
LT_BEGIN_AUTO_TEST(file_upload_suite, file_upload_path_traversal_rejected)
996+
string upload_directory = "upload_test_dir";
997+
MKDIR(upload_directory.c_str());
998+
999+
int port = PORT + 2;
1000+
auto ws = std::make_unique<webserver>(create_webserver(port)
1001+
.file_upload_target(httpserver::FILE_UPLOAD_DISK_ONLY)
1002+
.file_upload_dir(upload_directory));
1003+
// NOT using generate_random_filename_on_upload - this is the vulnerable path
1004+
ws->start(false);
1005+
LT_CHECK_EQ(ws->is_running(), true);
1006+
1007+
print_file_upload_resource resource;
1008+
LT_ASSERT_EQ(true, ws->register_resource("upload", &resource));
1009+
1010+
// Attempt path traversal with "../escape"
1011+
send_file_with_traversal_name(port, "../escape");
1012+
// The server should reject the upload (MHD_NO causes connection close)
1013+
// The key check is that no file was created outside the upload dir
1014+
LT_CHECK_EQ(file_exists("escape"), false);
1015+
LT_CHECK_EQ(file_exists("./escape"), false);
1016+
1017+
ws->stop();
1018+
1019+
// Clean up
1020+
rmdir(upload_directory.c_str());
1021+
LT_END_AUTO_TEST(file_upload_path_traversal_rejected)
1022+
1023+
// Test that sanitize keeps the basename for normal filenames
1024+
LT_BEGIN_AUTO_TEST(file_upload_suite, file_upload_sanitize_keeps_basename)
1025+
string upload_directory = "upload_test_dir";
1026+
MKDIR(upload_directory.c_str());
1027+
1028+
int port = PORT + 3;
1029+
auto ws = std::make_unique<webserver>(create_webserver(port)
1030+
.file_upload_target(httpserver::FILE_UPLOAD_DISK_ONLY)
1031+
.file_upload_dir(upload_directory));
1032+
ws->start(false);
1033+
LT_CHECK_EQ(ws->is_running(), true);
1034+
1035+
print_file_upload_resource resource;
1036+
LT_ASSERT_EQ(true, ws->register_resource("upload", &resource));
1037+
1038+
// Upload with a path-like filename — should strip to just "myfile.txt"
1039+
auto res = send_file_with_traversal_name(port, "some/path/myfile.txt");
1040+
LT_ASSERT_EQ(res.first, 0);
1041+
LT_ASSERT_EQ(res.second, 201);
1042+
1043+
// The file should be created with only the basename
1044+
map<string, map<string, httpserver::http::file_info>> files = resource.get_files();
1045+
LT_CHECK_EQ(files.size(), 1);
1046+
auto file = files.begin()->second.begin();
1047+
string expected_path = upload_directory + "/myfile.txt";
1048+
LT_CHECK_EQ(file->second.get_file_system_file_name(), expected_path);
1049+
1050+
ws->stop();
1051+
1052+
// Clean up
1053+
unlink(expected_path.c_str());
1054+
rmdir(upload_directory.c_str());
1055+
LT_END_AUTO_TEST(file_upload_sanitize_keeps_basename)
1056+
9671057
LT_BEGIN_AUTO_TEST_ENV()
9681058
AUTORUN_TESTS()
9691059
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)