From 7c44a7a5e134f64a1d05f06a93493bf4180c1267 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 16 Oct 2026 16:39:00 -0400 Subject: [PATCH] Compare extensions, header names and media types without case INDEX.HTML got the default type, a REMOTE_ADDR spelled in lower case got past the guard on prohibited header names, and Multipart/Form-Data was not taken for multipart, though RFC 9110 makes field names and media types case-insensitive and web servers match extensions so. --- httplib.h | 35 ++++++++++++++++++++++------------- test/test.cc | 10 ++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/httplib.h b/httplib.h index f5148206..717cd72f 100644 --- a/httplib.h +++ b/httplib.h @@ -7668,8 +7668,10 @@ find_content_type(const std::string &path, {"wasm", "application/wasm"}, }; + // Extensions are matched regardless of case, as web servers do + auto lower_ext = case_ignore::to_lower(ext); for (const auto &t : types) { - if (ext == t.ext) { return t.type; } + if (lower_ext == t.ext) { return t.type; } } return default_content_type; } @@ -7705,7 +7707,8 @@ extract_media_type(const std::string &content_type, } inline bool can_compress_content_type(const std::string &content_type) { - auto mime_type = extract_media_type(content_type); + // Media types are case-insensitive (RFC 9110 §8.3.1) + auto mime_type = case_ignore::to_lower(extract_media_type(content_type)); static const char *const compressible[] = { "image/svg+xml", @@ -8202,18 +8205,23 @@ create_compressor() { #endif } -// s == lit, comparing the lengths first, which operator== may not do. +// Whether s is lit, ignoring case, with the lengths compared first. template -inline bool equals_literal(const std::string &s, const char (&lit)[N]) { - return s.size() == N - 1 && !s.compare(0, N - 1, lit); +inline bool equals_literal_ignoring_case(const std::string &s, + const char (&lit)[N]) { + return s.size() == N - 1 && + std::equal(s.begin(), s.end(), lit, [](char a, char b) { + return case_ignore::to_lower(a) == case_ignore::to_lower(b); + }); } inline bool is_prohibited_header_name(const std::string &name) { - // Every header lookup by name runs this. - return equals_literal(name, "REMOTE_ADDR") || - equals_literal(name, "REMOTE_PORT") || - equals_literal(name, "LOCAL_ADDR") || - equals_literal(name, "LOCAL_PORT"); + // Field names are case-insensitive (RFC 9110 §5.1), and every header lookup + // by name runs this. + return equals_literal_ignoring_case(name, "REMOTE_ADDR") || + equals_literal_ignoring_case(name, "REMOTE_PORT") || + equals_literal_ignoring_case(name, "LOCAL_ADDR") || + equals_literal_ignoring_case(name, "LOCAL_PORT"); } inline bool has_header(const Headers &headers, const std::string &key) { @@ -11885,7 +11893,8 @@ inline size_t Request::get_param_value_count(const std::string &key) const { inline bool Request::is_multipart_form_data() const { const auto &content_type = get_header_value("Content-Type"); - return detail::extract_media_type(content_type) == "multipart/form-data"; + return detail::case_ignore::equal(detail::extract_media_type(content_type), + "multipart/form-data"); } // Multipart FormData implementation @@ -13931,8 +13940,8 @@ inline bool Server::read_content(Stream &strm, Request &req, Response &res) { return true; })) { const auto &content_type = req.get_header_value("Content-Type"); - if (detail::extract_media_type(content_type) == - "application/x-www-form-urlencoded") { + if (detail::case_ignore::equal(detail::extract_media_type(content_type), + "application/x-www-form-urlencoded")) { if (req.body.size() > CPPHTTPLIB_FORM_URL_ENCODED_PAYLOAD_MAX_LENGTH) { res.status = StatusCode::PayloadTooLarge_413; // NOTE: should be 414? output_error_log(Error::ExceedMaxPayloadSize, &req); diff --git a/test/test.cc b/test/test.cc index 2d168498..9cd1b6cf 100644 --- a/test/test.cc +++ b/test/test.cc @@ -681,6 +681,7 @@ TEST(FindContentTypeTest, ComparesWholeExtensions) { EXPECT_EQ("text/html", detail::find_content_type("/a.html", {}, "")); EXPECT_EQ("image/svg+xml", detail::find_content_type("/a.svg", {}, "")); EXPECT_EQ("text/javascript", detail::find_content_type("/a.js", {}, "")); + EXPECT_EQ("text/html", detail::find_content_type("/INDEX.HTML", {}, "")); // Same 26-bit hash as html, svg and js. EXPECT_EQ("", detail::find_content_type("/a.qo2yh", {}, "")); @@ -693,15 +694,24 @@ TEST(CanCompressContentTypeTest, ComparesWholeTypes) { EXPECT_TRUE(detail::can_compress_content_type("text/plain; charset=utf-8")); EXPECT_FALSE(detail::can_compress_content_type("text/event-stream")); EXPECT_FALSE(detail::can_compress_content_type("image/png")); + EXPECT_TRUE(detail::can_compress_content_type("Application/JSON")); + EXPECT_FALSE(detail::can_compress_content_type("Text/Event-Stream")); // Same 26-bit hash as application/json. EXPECT_FALSE(detail::can_compress_content_type("VY_LQ")); } +TEST(MultipartFormDataTest, MediaTypeIgnoresCase) { + Request req; + req.set_header("Content-Type", "Multipart/Form-Data; boundary=x"); + EXPECT_TRUE(req.is_multipart_form_data()); +} + TEST(ProhibitedHeaderNameTest, ComparesWholeNames) { EXPECT_TRUE(detail::is_prohibited_header_name("REMOTE_ADDR")); EXPECT_TRUE(detail::is_prohibited_header_name("LOCAL_PORT")); EXPECT_FALSE(detail::is_prohibited_header_name("Host")); + EXPECT_TRUE(detail::is_prohibited_header_name("remote_addr")); // Same 26-bit hash as REMOTE_ADDR. EXPECT_FALSE(detail::is_prohibited_header_name("IIJRp"));