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

+85 -47
View File
@@ -8857,6 +8857,12 @@ inline bool write_content_with_progress(Stream &strm,
data_sink.write = [&](const char *d, size_t l) -> bool {
if (ok) {
// Bytes past `length` would follow the framed body, where the peer reads
// them as the next message. Write only what fits and fail the rest.
if (l > end_offset - offset) {
l = end_offset - offset;
ok = false;
}
if (write_data(strm, d, l)) {
offset += l;
@@ -12060,6 +12066,58 @@ inline void prefer_assigned_body(Response &res) {
}
}
// RFC 9112 6.3: a 1xx, 204 or 304 response ends with its header section.
inline bool body_allowed_for_status(int status) {
return status >= 200 && status != StatusCode::NoContent_204 &&
status != StatusCode::NotModified_304;
}
// Only a 206 response is served as a partial representation. range_error()
// validates `req.ranges` only for a 2xx status, so slicing under any other
// status would start from an unchecked offset.
inline bool is_partial_content(const Request &req, const Response &res) {
return !req.ranges.empty() && res.status == StatusCode::PartialContent_206;
}
// Sets the Content-Length or Transfer-Encoding framing the body that
// Server::write_response_core() writes. Only the library sets them: one from a
// handler would go out next to it, or describe some other body.
inline void set_framing_headers(const Request &req, Response &res,
const std::string &boundary,
const std::string &content_type) {
// A HEAD response may give the length a GET would have had (RFC 9110 9.3.2).
auto head_length = req.method == "HEAD"
? res.get_header_value("Content-Length")
: std::string();
res.headers.erase("Content-Length");
res.headers.erase("Transfer-Encoding");
if (!body_allowed_for_status(res.status)) { return; }
if (res.content_provider_) {
if (res.content_length_ == 0) {
if (res.is_chunked_content_provider_) {
res.set_header("Transfer-Encoding", "chunked");
}
return;
}
// The part Server::write_content_with_provider() writes.
auto length = res.content_length_;
if (is_partial_content(req, res)) {
length = req.ranges.size() == 1
? get_range_offset_and_length(req.ranges[0], length).second
: get_multipart_ranges_data_length(req, boundary,
content_type, length);
}
res.set_header("Content-Length", std::to_string(length));
} else if (!res.body.empty()) {
res.set_header("Content-Length", std::to_string(res.body.size()));
} else {
res.set_header("Content-Length", head_length.empty() ? "0" : head_length);
}
}
} // namespace detail
inline size_t Response::get_header_value_u64(const std::string &key, size_t def,
@@ -13777,6 +13835,9 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
need_apply_ranges = true;
}
if (!detail::body_allowed_for_status(res.status)) {
detail::clear_content(res);
}
detail::prefer_assigned_body(res);
std::string content_type;
@@ -13807,16 +13868,17 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
res.set_header("Content-Type", "text/plain");
}
if (res.body.empty() && !res.content_length_ && !res.content_provider_ &&
!res.has_header("Content-Length")) {
res.set_header("Content-Length", "0");
}
detail::set_framing_headers(req, res, boundary, content_type);
if (req.method == "HEAD" && !res.has_header("Accept-Ranges")) {
res.set_header("Accept-Ranges", "bytes");
}
if (post_routing_handler_) { post_routing_handler_(req, res); }
if (post_routing_handler_) {
post_routing_handler_(req, res);
// Again, for a body or framing headers the handler changed.
detail::set_framing_headers(req, res, boundary, content_type);
}
// Response line and headers
detail::BufferStream bstrm;
@@ -13826,7 +13888,8 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
// Combine a small body with the headers so the whole response leaves in a
// single write. A large body is written on its own instead: a copy of it
// costs more than the extra write saves.
auto send_body = req.method != "HEAD";
auto send_body =
req.method != "HEAD" && detail::body_allowed_for_status(res.status);
auto body_is_separate = false;
auto provider_done = false;
if (send_body && !res.body.empty() && !res.content_provider_) {
@@ -13879,15 +13942,7 @@ Server::write_content_with_provider(Stream &strm, const Request &req,
};
if (res.content_length_ > 0) {
// Only a 206 response is served as a partial representation, matching the
// condition `apply_ranges()` used to decide the Content-Length and the
// multipart boundary. Since `detail::range_error()` validates `req.ranges`
// only for a 2xx status, slicing under any other status would write a body
// that disagrees with the header already sent, from an unchecked offset.
auto is_partial =
!req.ranges.empty() && res.status == StatusCode::PartialContent_206;
if (!is_partial) {
if (!detail::is_partial_content(req, res)) {
return detail::write_content(strm, res.content_provider_, 0,
res.content_length_, is_shutting_down);
} else if (req.ranges.size() == 1) {
@@ -14588,8 +14643,8 @@ Server::static_file_encoding(const Request &req, const Response &res,
return detail::encoding_type(req, res, content_type);
}
// Compresses a file-backed content provider into `res.body` and takes over the
// framing headers. Returns false when the response is left untouched.
// Compresses a file-backed content provider into `res.body`. Returns false when
// the response is left untouched.
inline bool Server::apply_static_file_compression(const Request &req,
Response &res) const {
auto type = res.content_coding_;
@@ -14617,7 +14672,6 @@ inline bool Server::apply_static_file_compression(const Request &req,
res.set_header("Content-Encoding", detail::encoding_name(type));
res.set_header("Vary", "Accept-Encoding");
res.set_header("Content-Length", std::to_string(res.body.size()));
return true;
}
@@ -14631,7 +14685,7 @@ inline void Server::apply_ranges(const Request &req, Response &res,
// ordinary body: `Content-Length` and HEAD keep working, and the response
// takes the same path as `set_content()` from here on. Range requests never
// get a content coding, so `Content-Range` still names identity bytes and
// none of the framing below applies.
// none of the range handling below applies.
if (apply_static_file_compression(req, res)) { return; }
if (req.ranges.size() > 1 && res.status == StatusCode::PartialContent_206) {
@@ -14650,34 +14704,19 @@ inline void Server::apply_ranges(const Request &req, Response &res,
auto type = detail::encoding_type(req, res);
if (res.body.empty()) {
if (res.content_length_ > 0) {
size_t length = 0;
if (req.ranges.empty() || res.status != StatusCode::PartialContent_206) {
length = res.content_length_;
} else if (req.ranges.size() == 1) {
auto offset_and_length = detail::get_range_offset_and_length(
req.ranges[0], res.content_length_);
if (res.content_length_ > 0 && req.ranges.size() == 1 &&
res.status == StatusCode::PartialContent_206) {
auto offset_and_length = detail::get_range_offset_and_length(
req.ranges[0], res.content_length_);
length = offset_and_length.second;
auto content_range = detail::make_content_range_header_field(
offset_and_length, res.content_length_);
res.set_header("Content-Range", content_range);
} else {
length = detail::get_multipart_ranges_data_length(
req, boundary, content_type, res.content_length_);
}
res.set_header("Content-Length", std::to_string(length));
} else {
if (res.content_provider_) {
if (res.is_chunked_content_provider_) {
res.set_header("Transfer-Encoding", "chunked");
res.content_coding_ = type;
if (type != detail::EncodingType::None) {
res.set_header("Content-Encoding", detail::encoding_name(type));
res.set_header("Vary", "Accept-Encoding");
}
}
auto content_range = detail::make_content_range_header_field(
offset_and_length, res.content_length_);
res.set_header("Content-Range", content_range);
} else if (res.is_chunked_content_provider_) {
res.content_coding_ = type;
if (type != detail::EncodingType::None) {
res.set_header("Content-Encoding", detail::encoding_name(type));
res.set_header("Vary", "Accept-Encoding");
}
}
} else {
@@ -14720,7 +14759,6 @@ inline void Server::apply_ranges(const Request &req, Response &res,
}
res.content_length_ = res.body.size();
res.set_header("Content-Length", std::to_string(res.content_length_));
}
}