Match chunked as the final transfer coding, order-independently (#2500)

is_chunked_transfer_encoding compared the whole Transfer-Encoding field
value against "chunked", so a message whose final coding is chunked but
which names another coding first ("gzip, chunked", valid under RFC 9112
6.1) was read as unframed. On a keep-alive server the body was then left
in the socket and parsed as a smuggled request; open_stream repeated the
check case-sensitively with a raw ==, desyncing the client stream the
same way.

Rework the helper to match the last coding token case-insensitively and
route open_stream through it so both paths agree. The codings may also be
split across multiple Transfer-Encoding lines (RFC 9110 5.3); since
Headers is an unordered_multimap whose duplicate-key iteration order is
not portable, the final coding of a multi-line field cannot be
determined reliably, so treat any such message that names chunked as
chunked (fail safe: a mis-parse only closes the connection, whereas the
opposite error enables smuggling). A unit test covers the helper,
including order-independent multi-line cases.

Based on #2487 by @metsw24-max.
This commit is contained in:
yhirose
2026-07-18 18:07:29 -04:00
committed by GitHub
parent 0c1cc8c986
commit 75938f08c7
2 changed files with 84 additions and 5 deletions
+42
View File
@@ -420,6 +420,48 @@ TEST(SanitizeFilenameTest, VariousPatterns) {
EXPECT_EQ("", httplib::sanitize_filename(" "));
}
// Forward declaration (see the base64_encode note below): split builds move the
// definition into httplib.cc, so re-declaring it here lets the test link.
namespace httplib {
namespace detail {
bool is_chunked_transfer_encoding(const Headers &headers);
} // namespace detail
} // namespace httplib
TEST(ChunkedTransferEncodingTest, DetectsChunkedAsFinalCoding) {
// Build a Headers with the given Transfer-Encoding lines (nullptr = none).
auto make = [](std::initializer_list<const char *> lines) {
Headers h;
for (auto te : lines) {
if (te) { h.emplace("Transfer-Encoding", te); }
}
return h;
};
// Sole coding, matched case-insensitively.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"Chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"CHUNKED"})));
// RFC 9112 6.1: chunked as the final coding of a list still frames the body.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip, chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip,chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip, Chunked "})));
// Single line: chunked absent, or not the final coding -> not chunk-framed.
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"chunked, gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({""})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({nullptr})));
// Multiple Transfer-Encoding lines: iteration order for duplicate keys is not
// portable, so any line naming chunked is treated as chunked (fail safe).
// The result must not depend on the order the lines were added.
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"gzip", "chunked"})));
EXPECT_TRUE(detail::is_chunked_transfer_encoding(make({"chunked", "gzip"})));
EXPECT_FALSE(detail::is_chunked_transfer_encoding(make({"gzip", "deflate"})));
}
// Forward declaration: in split builds split.py strips `inline` and moves the
// definition into httplib.cc, so detail::base64_encode is not visible from the
// public httplib.h. Re-declaring it here lets the tests link against the symbol