From ef2b0a8d0b7057672dc954ee6755f20ed704ea3f Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 5 Dec 2025 15:23:58 -0500 Subject: [PATCH] Enhance ETag handling and validation in httplib.h and add comprehensive tests in test.cc --- httplib.h | 33 +++++++++---- test/test.cc | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/httplib.h b/httplib.h index fc1ac84..a2057f2 100644 --- a/httplib.h +++ b/httplib.h @@ -2980,24 +2980,37 @@ inline std::string from_i_to_hex(size_t n) { inline std::string compute_etag(const FileStat &fs) { if (!fs.is_file()) { return std::string(); } + // If mtime cannot be determined (negative value indicates an error + // or sentinel), do not generate an ETag. Returning a neutral / fixed + // value like 0 could collide with a real file that legitimately has + // mtime == 0 (epoch) and lead to misleading validators. auto mtime_raw = fs.mtime(); - auto mtime = mtime_raw < 0 ? 0 : static_cast(mtime_raw); + if (mtime_raw < 0) { return std::string(); } + + auto mtime = static_cast(mtime_raw); auto size = fs.size(); return std::string("W/\"") + from_i_to_hex(mtime) + "-" + from_i_to_hex(size) + "\""; } -// Format time_t as HTTP-date (RFC 7231): "Sun, 06 Nov 1994 08:49:37 GMT" +// Format time_t as HTTP-date (RFC 9110 Section 5.6.7): "Sun, 06 Nov 1994 +// 08:49:37 GMT" This implementation is defensive: it validates `mtime`, checks +// return values from `gmtime_r`/`gmtime_s`, and ensures `strftime` succeeds. inline std::string file_mtime_to_http_date(time_t mtime) { + if (mtime < 0) { return std::string(); } + struct tm tm_buf; #ifdef _WIN32 - gmtime_s(&tm_buf, &mtime); + if (gmtime_s(&tm_buf, &mtime) != 0) { return std::string(); } #else - gmtime_r(&mtime, &tm_buf); + if (gmtime_r(&mtime, &tm_buf) == nullptr) { return std::string(); } #endif char buf[64]; - strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf); + if (strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf) == 0) { + return std::string(); + } + return std::string(buf); } @@ -3043,7 +3056,8 @@ inline bool is_weak_etag(const std::string &s) { } inline bool is_strong_etag(const std::string &s) { - // Check if the string is a strong ETag (starts and ends with '"', at least 2 chars) + // Check if the string is a strong ETag (starts and ends with '"', at least 2 + // chars) return s.size() >= 2 && s[0] == '"' && s.back() == '"'; } @@ -3167,7 +3181,8 @@ inline bool FileStat::is_dir() const { } inline time_t FileStat::mtime() const { - return ret_ >= 0 ? static_cast(st_.st_mtime) : static_cast(-1); + return ret_ >= 0 ? static_cast(st_.st_mtime) + : static_cast(-1); } inline size_t FileStat::size() const { @@ -8460,7 +8475,9 @@ inline bool Server::check_if_not_modified(const Request &req, Response &res, [&](const char *b, const char *e) { auto len = static_cast(e - b); if (len == 1 && *b == '*') return true; - if (len == etag.size() && std::equal(b, e, etag.begin())) return true; + if (len == etag.size() && + std::equal(b, e, etag.begin())) + return true; return false; }); diff --git a/test/test.cc b/test/test.cc index a8424a7..0380acf 100644 --- a/test/test.cc +++ b/test/test.cc @@ -12716,7 +12716,7 @@ TEST(ETagTest, StaticFileETagAndIfNoneMatch) { EXPECT_FALSE(etag.empty()); // Verify ETag format: W/"hex-hex" - ASSERT_GE(etag.length(), 5u); // Minimum: W/"" + ASSERT_GE(etag.length(), 5u); // Minimum: W/"" EXPECT_EQ('W', etag[0]); EXPECT_EQ('/', etag[1]); EXPECT_EQ('"', etag[2]); @@ -12967,3 +12967,134 @@ TEST(ETagTest, IfRangeWithDate) { t.join(); std::remove(fname); } +TEST(ETagTest, MalformedIfNoneMatchAndWhitespace) { + using namespace httplib; + + const char *fname = "etag_malformed.txt"; + const char *content = "malformed-etag"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8092); }); + svr.wait_until_ready(); + + Client cli("localhost", 8092); + + // baseline: should get 200 and an ETag + auto res1 = cli.Get("/static/etag_malformed.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + + // Malformed ETag value (missing quotes) should be treated as non-matching + Headers h_bad = {{"If-None-Match", "W/noquotes"}}; + auto res_bad = cli.Get("/static/etag_malformed.txt", h_bad); + ASSERT_TRUE(res_bad); + EXPECT_EQ(200, res_bad->status); + + // Whitespace-only header value should be considered invalid / non-matching + Headers h_space = {{"If-None-Match", " "}}; + auto res_space = cli.Get("/static/etag_malformed.txt", h_space); + ASSERT_TRUE(res_space); + EXPECT_EQ(200, res_space->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, InvalidIfModifiedSinceAndIfRangeDate) { + using namespace httplib; + + const char *fname = "ims_invalid_format.txt"; + const char *content = "ims-bad-format"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8093); }); + svr.wait_until_ready(); + + Client cli("localhost", 8093); + + auto res1 = cli.Get("/static/ims_invalid_format.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + + // If-Modified-Since with invalid format should not result in 304 + Headers h_bad_date = {{"If-Modified-Since", "not-a-valid-date"}}; + auto res_bad = cli.Get("/static/ims_invalid_format.txt", h_bad_date); + ASSERT_TRUE(res_bad); + EXPECT_EQ(200, res_bad->status); + + // If-Range with invalid date format should be treated as mismatch -> full + // content (200) + Headers h_ifrange_bad = {{"Range", "bytes=0-3"}, + {"If-Range", "invalid-date"}}; + auto res_ifrange = cli.Get("/static/ims_invalid_format.txt", h_ifrange_bad); + ASSERT_TRUE(res_ifrange); + EXPECT_EQ(200, res_ifrange->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, IfRangeWithMalformedETag) { + using namespace httplib; + + const char *fname = "ifrange_malformed.txt"; + const std::string content = "0123456789"; + { + std::ofstream ofs(fname); + ofs << content; + ASSERT_TRUE(ofs.good()); + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8094); }); + svr.wait_until_ready(); + + Client cli("localhost", 8094); + + // First request: get ETag + auto res1 = cli.Get("/static/ifrange_malformed.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + + // If-Range with malformed ETag (no quotes) should be treated as mismatch -> + // full content (200) + Headers h_malformed = {{"Range", "bytes=0-4"}, {"If-Range", "W/noquotes"}}; + auto res2 = cli.Get("/static/ifrange_malformed.txt", h_malformed); + ASSERT_TRUE(res2); + EXPECT_EQ(200, res2->status); + EXPECT_EQ(content, res2->body); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, DateParsingAndMtimeNegative) { + using namespace httplib; + + // parse_http_date should return -1 for invalid format + time_t parsed = detail::parse_http_date("this is not a date"); + EXPECT_EQ(static_cast(-1), parsed); + + // file_mtime_to_http_date returns empty string for negative mtime + std::string s = detail::file_mtime_to_http_date(static_cast(-1)); + EXPECT_TRUE(s.empty()); +}