Frame response bodies in one place

A content provider that wrote past the length it was given put the
extra bytes after the framed body, where a keep-alive peer read them as
the next message. Write only what fits and fail the write, on both the
server and the client.

Content-Length and Transfer-Encoding a handler set were sent next to
the ones the library worked out, for ranges and compression, and a
post-routing handler could change the body after Content-Length was
set. Work both out in one place, from the body about to be written,
again after the post-routing handler; a HEAD response keeps the length
its handler gave it. A 1xx, 204 or 304 response sends no body and,
following RFC 9110 8.6, no Content-Length.

The client still sends a Content-Length or Transfer-Encoding its caller
passes, as curl and urllib3 do, and the README says these have to match
the body.
This commit is contained in:
yhirose committed 2026-10-16 19:13:12 -04:00
1 parent bc837b87fc
commit 43c9c6ddf3
3 files changed
+289 -49

No files matched your search

+200 -2
View File
@@ -5355,10 +5355,99 @@ TEST(NoContentTest, ContentLength) {
auto res = cli.Get("/hi");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NoContent_204, res->status);
EXPECT_EQ("0", res->get_header_value("Content-Length"));
// RFC 9110 8.6: none in a 204 response
EXPECT_FALSE(res->has_header("Content-Length"));
}
}
TEST(ResponseFramingTest, HandlerFramingHeadersAreReplaced) {
Server svr;
svr.Get("/body", [](const Request & /*req*/, Response &res) {
res.set_header("Content-Length", "5");
res.set_content("hello world", "text/plain");
});
svr.Get("/chunked", [](const Request & /*req*/, Response &res) {
// A body that reads as the last chunk, were it taken as chunked
res.set_header("Transfer-Encoding", "chunked");
res.set_content("0\r\n\r\n", "text/plain");
});
svr.Get("/provider", [](const Request & /*req*/, Response &res) {
res.set_header("Content-Length", "2");
res.set_content_provider(5, "text/plain",
[](size_t offset, size_t length, DataSink &sink) {
sink.write(&"hello"[offset], length);
return true;
});
});
svr.Get("/empty", [](const Request & /*req*/, Response &res) {
res.set_header("Content-Length", "1234");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
cli.set_read_timeout(1, 0);
auto expect_body = [&](const Result &res, const std::string &body) {
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(body, res->body);
EXPECT_EQ(1u, res->get_header_value_count("Content-Length"));
EXPECT_FALSE(res->has_header("Transfer-Encoding"));
};
Headers identity{{"Accept-Encoding", ""}};
expect_body(cli.Get("/body", identity), "hello world");
expect_body(cli.Get("/chunked", identity), "0\r\n\r\n");
expect_body(cli.Get("/body",
Headers{{"Accept-Encoding", ""}, {"Range", "bytes=0-1"}}),
"he");
expect_body(cli.Get("/provider", identity), "hello");
expect_body(cli.Get("/empty", identity), "");
// A HEAD response may give the length of the body a GET would get.
auto res = cli.Head("/empty");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("1234", res->get_header_value("Content-Length"));
}
TEST(ResponseFramingTest, PostRoutingHandlerChangesAreFramed) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content("short", "text/plain");
});
svr.set_post_routing_handler([](const Request & /*req*/, Response &res) {
res.body += " with a footer";
res.set_header("Transfer-Encoding", "chunked");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
cli.set_read_timeout(1, 0);
auto res = cli.Get("/", Headers{{"Accept-Encoding", ""}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("short with a footer", res->body);
EXPECT_EQ("19", res->get_header_value("Content-Length"));
EXPECT_FALSE(res->has_header("Transfer-Encoding"));
}
TEST(RoutingHandlerTest, PreAndPostRoutingHandlers) {
#ifdef CPPHTTPLIB_SSL_ENABLED
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
@@ -11604,7 +11693,7 @@ TEST(UploadProgressTest, PostContentProviderProgress) {
},
[](Client &cli, UploadProgress progress_callback) {
return cli.Post(
"/test", 10,
"/test", 9,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) -> bool {
sink.os << "test data";
return true;
@@ -12617,6 +12706,115 @@ TEST(DataSinkTest, LengthFramedProviderThatFinishesEarlyFailsTheResponse) {
EXPECT_FALSE(res);
}
// Read as the next response by a peer that sees it after a framed body.
static const std::string smuggled_response =
"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nSMUGGLED";
TEST(DataSinkTest, LengthFramedProviderCannotWritePastTheLength) {
Server svr;
svr.Get("/", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
5, "text/plain",
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
auto data = "AAAAA" + smuggled_response;
sink.write(data.data(), data.size());
return true;
});
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
std::string resp;
ASSERT_TRUE(send_request(1, "GET / HTTP/1.1\r\nHost: x\r\n\r\n", &resp));
EXPECT_EQ("AAAAA", resp.substr(resp.find("\r\n\r\n") + 4));
resp.clear();
ASSERT_TRUE(send_request(
1, "GET / HTTP/1.1\r\nHost: x\r\nRange: bytes=0-1\r\n\r\n", &resp));
EXPECT_EQ("AA", resp.substr(resp.find("\r\n\r\n") + 4));
}
TEST(NoContentTest, BodyIsNotSent) {
Server svr;
svr.Get("/204", [](const Request & /*req*/, Response &res) {
res.status = StatusCode::NoContent_204;
res.set_content(smuggled_response, "text/plain");
});
svr.Get("/304", [](const Request & /*req*/, Response &res) {
res.status = StatusCode::NotModified_304;
res.set_chunked_content_provider(
"text/plain", [](size_t /*offset*/, DataSink &sink) {
sink.write(smuggled_response.data(), smuggled_response.size());
sink.done();
return true;
});
});
thread t = thread([&] { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
for (auto path : {"/204", "/304"}) {
std::string resp;
ASSERT_TRUE(send_request(
1, std::string("GET ") + path + " HTTP/1.1\r\nHost: x\r\n\r\n", &resp));
EXPECT_EQ(resp.size(), resp.find("\r\n\r\n") + 4) << path;
EXPECT_EQ(std::string::npos, resp.find("Content-Length")) << path;
EXPECT_EQ(std::string::npos, resp.find("Transfer-Encoding")) << path;
}
}
TEST(DataSinkTest, RequestProviderCannotWritePastTheLength) {
Server svr;
std::atomic<bool> smuggled(false);
svr.Post("/", [](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
svr.Get("/smuggled", [&](const Request & /*req*/, Response & /*res*/) {
smuggled = true;
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
listen_thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// What follows the body would be read as the next request.
Client cli(HOST, port);
auto res = cli.Post(
"/", 5,
[](size_t /*offset*/, size_t /*length*/, DataSink &sink) {
std::string data = "helloGET /smuggled HTTP/1.1\r\nHost: x\r\n\r\n";
sink.write(data.data(), data.size());
return true;
},
"text/plain");
ASSERT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
EXPECT_FALSE(smuggled);
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST(DataSinkTest, CompressedRequestProviderThatFinishesEarlyFails) {
// The compressed path builds the whole body before connecting, so this