Skip to content

Commit ef2b0a8

Browse files
committed
Enhance ETag handling and validation in httplib.h and add comprehensive tests in test.cc
1 parent 08e237b commit ef2b0a8

File tree

2 files changed

+157
-9
lines changed

2 files changed

+157
-9
lines changed

httplib.h

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2980,24 +2980,37 @@ inline std::string from_i_to_hex(size_t n) {
29802980
inline std::string compute_etag(const FileStat &fs) {
29812981
if (!fs.is_file()) { return std::string(); }
29822982

2983+
// If mtime cannot be determined (negative value indicates an error
2984+
// or sentinel), do not generate an ETag. Returning a neutral / fixed
2985+
// value like 0 could collide with a real file that legitimately has
2986+
// mtime == 0 (epoch) and lead to misleading validators.
29832987
auto mtime_raw = fs.mtime();
2984-
auto mtime = mtime_raw < 0 ? 0 : static_cast<size_t>(mtime_raw);
2988+
if (mtime_raw < 0) { return std::string(); }
2989+
2990+
auto mtime = static_cast<size_t>(mtime_raw);
29852991
auto size = fs.size();
29862992

29872993
return std::string("W/\"") + from_i_to_hex(mtime) + "-" +
29882994
from_i_to_hex(size) + "\"";
29892995
}
29902996

2991-
// Format time_t as HTTP-date (RFC 7231): "Sun, 06 Nov 1994 08:49:37 GMT"
2997+
// Format time_t as HTTP-date (RFC 9110 Section 5.6.7): "Sun, 06 Nov 1994
2998+
// 08:49:37 GMT" This implementation is defensive: it validates `mtime`, checks
2999+
// return values from `gmtime_r`/`gmtime_s`, and ensures `strftime` succeeds.
29923000
inline std::string file_mtime_to_http_date(time_t mtime) {
3001+
if (mtime < 0) { return std::string(); }
3002+
29933003
struct tm tm_buf;
29943004
#ifdef _WIN32
2995-
gmtime_s(&tm_buf, &mtime);
3005+
if (gmtime_s(&tm_buf, &mtime) != 0) { return std::string(); }
29963006
#else
2997-
gmtime_r(&mtime, &tm_buf);
3007+
if (gmtime_r(&mtime, &tm_buf) == nullptr) { return std::string(); }
29983008
#endif
29993009
char buf[64];
3000-
strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf);
3010+
if (strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf) == 0) {
3011+
return std::string();
3012+
}
3013+
30013014
return std::string(buf);
30023015
}
30033016

@@ -3043,7 +3056,8 @@ inline bool is_weak_etag(const std::string &s) {
30433056
}
30443057

30453058
inline bool is_strong_etag(const std::string &s) {
3046-
// Check if the string is a strong ETag (starts and ends with '"', at least 2 chars)
3059+
// Check if the string is a strong ETag (starts and ends with '"', at least 2
3060+
// chars)
30473061
return s.size() >= 2 && s[0] == '"' && s.back() == '"';
30483062
}
30493063

@@ -3167,7 +3181,8 @@ inline bool FileStat::is_dir() const {
31673181
}
31683182

31693183
inline time_t FileStat::mtime() const {
3170-
return ret_ >= 0 ? static_cast<time_t>(st_.st_mtime) : static_cast<time_t>(-1);
3184+
return ret_ >= 0 ? static_cast<time_t>(st_.st_mtime)
3185+
: static_cast<time_t>(-1);
31713186
}
31723187

31733188
inline size_t FileStat::size() const {
@@ -8460,7 +8475,9 @@ inline bool Server::check_if_not_modified(const Request &req, Response &res,
84608475
[&](const char *b, const char *e) {
84618476
auto len = static_cast<size_t>(e - b);
84628477
if (len == 1 && *b == '*') return true;
8463-
if (len == etag.size() && std::equal(b, e, etag.begin())) return true;
8478+
if (len == etag.size() &&
8479+
std::equal(b, e, etag.begin()))
8480+
return true;
84648481
return false;
84658482
});
84668483

test/test.cc

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12716,7 +12716,7 @@ TEST(ETagTest, StaticFileETagAndIfNoneMatch) {
1271612716
EXPECT_FALSE(etag.empty());
1271712717

1271812718
// Verify ETag format: W/"hex-hex"
12719-
ASSERT_GE(etag.length(), 5u); // Minimum: W/""
12719+
ASSERT_GE(etag.length(), 5u); // Minimum: W/""
1272012720
EXPECT_EQ('W', etag[0]);
1272112721
EXPECT_EQ('/', etag[1]);
1272212722
EXPECT_EQ('"', etag[2]);
@@ -12967,3 +12967,134 @@ TEST(ETagTest, IfRangeWithDate) {
1296712967
t.join();
1296812968
std::remove(fname);
1296912969
}
12970+
TEST(ETagTest, MalformedIfNoneMatchAndWhitespace) {
12971+
using namespace httplib;
12972+
12973+
const char *fname = "etag_malformed.txt";
12974+
const char *content = "malformed-etag";
12975+
{
12976+
std::ofstream ofs(fname);
12977+
ofs << content;
12978+
ASSERT_TRUE(ofs.good());
12979+
}
12980+
12981+
Server svr;
12982+
svr.set_mount_point("/static", ".");
12983+
auto t = std::thread([&]() { svr.listen("localhost", 8092); });
12984+
svr.wait_until_ready();
12985+
12986+
Client cli("localhost", 8092);
12987+
12988+
// baseline: should get 200 and an ETag
12989+
auto res1 = cli.Get("/static/etag_malformed.txt");
12990+
ASSERT_TRUE(res1);
12991+
ASSERT_EQ(200, res1->status);
12992+
ASSERT_TRUE(res1->has_header("ETag"));
12993+
12994+
// Malformed ETag value (missing quotes) should be treated as non-matching
12995+
Headers h_bad = {{"If-None-Match", "W/noquotes"}};
12996+
auto res_bad = cli.Get("/static/etag_malformed.txt", h_bad);
12997+
ASSERT_TRUE(res_bad);
12998+
EXPECT_EQ(200, res_bad->status);
12999+
13000+
// Whitespace-only header value should be considered invalid / non-matching
13001+
Headers h_space = {{"If-None-Match", " "}};
13002+
auto res_space = cli.Get("/static/etag_malformed.txt", h_space);
13003+
ASSERT_TRUE(res_space);
13004+
EXPECT_EQ(200, res_space->status);
13005+
13006+
svr.stop();
13007+
t.join();
13008+
std::remove(fname);
13009+
}
13010+
13011+
TEST(ETagTest, InvalidIfModifiedSinceAndIfRangeDate) {
13012+
using namespace httplib;
13013+
13014+
const char *fname = "ims_invalid_format.txt";
13015+
const char *content = "ims-bad-format";
13016+
{
13017+
std::ofstream ofs(fname);
13018+
ofs << content;
13019+
ASSERT_TRUE(ofs.good());
13020+
}
13021+
13022+
Server svr;
13023+
svr.set_mount_point("/static", ".");
13024+
auto t = std::thread([&]() { svr.listen("localhost", 8093); });
13025+
svr.wait_until_ready();
13026+
13027+
Client cli("localhost", 8093);
13028+
13029+
auto res1 = cli.Get("/static/ims_invalid_format.txt");
13030+
ASSERT_TRUE(res1);
13031+
ASSERT_EQ(200, res1->status);
13032+
ASSERT_TRUE(res1->has_header("Last-Modified"));
13033+
13034+
// If-Modified-Since with invalid format should not result in 304
13035+
Headers h_bad_date = {{"If-Modified-Since", "not-a-valid-date"}};
13036+
auto res_bad = cli.Get("/static/ims_invalid_format.txt", h_bad_date);
13037+
ASSERT_TRUE(res_bad);
13038+
EXPECT_EQ(200, res_bad->status);
13039+
13040+
// If-Range with invalid date format should be treated as mismatch -> full
13041+
// content (200)
13042+
Headers h_ifrange_bad = {{"Range", "bytes=0-3"},
13043+
{"If-Range", "invalid-date"}};
13044+
auto res_ifrange = cli.Get("/static/ims_invalid_format.txt", h_ifrange_bad);
13045+
ASSERT_TRUE(res_ifrange);
13046+
EXPECT_EQ(200, res_ifrange->status);
13047+
13048+
svr.stop();
13049+
t.join();
13050+
std::remove(fname);
13051+
}
13052+
13053+
TEST(ETagTest, IfRangeWithMalformedETag) {
13054+
using namespace httplib;
13055+
13056+
const char *fname = "ifrange_malformed.txt";
13057+
const std::string content = "0123456789";
13058+
{
13059+
std::ofstream ofs(fname);
13060+
ofs << content;
13061+
ASSERT_TRUE(ofs.good());
13062+
}
13063+
13064+
Server svr;
13065+
svr.set_mount_point("/static", ".");
13066+
auto t = std::thread([&]() { svr.listen("localhost", 8094); });
13067+
svr.wait_until_ready();
13068+
13069+
Client cli("localhost", 8094);
13070+
13071+
// First request: get ETag
13072+
auto res1 = cli.Get("/static/ifrange_malformed.txt");
13073+
ASSERT_TRUE(res1);
13074+
ASSERT_EQ(200, res1->status);
13075+
ASSERT_TRUE(res1->has_header("ETag"));
13076+
13077+
// If-Range with malformed ETag (no quotes) should be treated as mismatch ->
13078+
// full content (200)
13079+
Headers h_malformed = {{"Range", "bytes=0-4"}, {"If-Range", "W/noquotes"}};
13080+
auto res2 = cli.Get("/static/ifrange_malformed.txt", h_malformed);
13081+
ASSERT_TRUE(res2);
13082+
EXPECT_EQ(200, res2->status);
13083+
EXPECT_EQ(content, res2->body);
13084+
13085+
svr.stop();
13086+
t.join();
13087+
std::remove(fname);
13088+
}
13089+
13090+
TEST(ETagTest, DateParsingAndMtimeNegative) {
13091+
using namespace httplib;
13092+
13093+
// parse_http_date should return -1 for invalid format
13094+
time_t parsed = detail::parse_http_date("this is not a date");
13095+
EXPECT_EQ(static_cast<time_t>(-1), parsed);
13096+
13097+
// file_mtime_to_http_date returns empty string for negative mtime
13098+
std::string s = detail::file_mtime_to_http_date(static_cast<time_t>(-1));
13099+
EXPECT_TRUE(s.empty());
13100+
}

0 commit comments

Comments
 (0)