Fix 303 redirect request bodies and Location path decoding (Fix #2606, #2607)

A 303 response turns the follow-up request into a GET, but only the buffered body and headers were cleared. A content provider (sized or chunked) stayed on the request, so the original payload was sent again, and for a chunked provider the unframed chunks also broke the keep-alive connection.

The redirect also percent-decoded the Location path before sending it. That turned %23, %3F and %25 into a fragment, a query delimiter and a different octet, so the client requested a different resource than the one named, and made set_path_encode(false) fail on any Location containing %20. The path is now sent as given.
This commit is contained in:
yhirose
2026-10-03 19:06:45 -04:00
parent 438319cfcb
commit 09fa6820fe
2 changed files with 143 additions and 9 deletions
+7 -4
View File
@@ -8976,6 +8976,9 @@ inline bool redirect(T &cli, Request &req, Response &res,
new_req.method = "GET"; new_req.method = "GET";
new_req.body.clear(); new_req.body.clear();
new_req.headers.clear(); new_req.headers.clear();
new_req.content_length_ = 0;
new_req.content_provider_ = nullptr;
new_req.is_chunked_content_provider_ = false;
} }
Response new_res; Response new_res;
@@ -15738,7 +15741,7 @@ inline bool ClientImpl::redirect(Request &req, Response &res, Error &error) {
if (next_host.empty()) { next_host = host_; } if (next_host.empty()) { next_host = host_; }
if (next_path.empty()) { next_path = "/"; } if (next_path.empty()) { next_path = "/"; }
auto path = decode_path_component(next_path) + next_query; auto path = std::move(next_path) + next_query;
// Same host redirect - use current client // Same host redirect - use current client
if (next_scheme == scheme && next_host == host_ && next_port == port_) { if (next_scheme == scheme && next_host == host_ && next_port == port_) {
@@ -15993,9 +15996,9 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// Write request line and headers // Write request line and headers
if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) { if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) {
// A rejected method (not a token, e.g. carrying CR/LF) or target (e.g. // A rejected method (not a token, e.g. carrying CR/LF) or target (e.g.
// CR/LF smuggled in via a decoded redirect Location under // CR/LF in a caller-supplied path under set_path_encode(false)) must
// set_path_encode(false)) must fail the request cleanly instead of // fail the request cleanly instead of emitting a request-line-less,
// emitting a request-line-less, header-injecting request. // header-injecting request.
error = Error::Write; error = Error::Write;
rejected_locally = true; rejected_locally = true;
output_error_log(error, &req); output_error_log(error, &req);
+136 -5
View File
@@ -10209,8 +10209,7 @@ TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
// A target carrying CR/LF, SP or other control octets must be rejected // A target carrying CR/LF, SP or other control octets must be rejected
// before anything reaches the wire, otherwise it splits the request line and // before anything reaches the wire, otherwise it splits the request line and
// injects a header or a whole request. This is what a decoded redirect // injects a header or a whole request.
// Location ("%0D%0A") turns into when path encoding is disabled.
const std::string evil_targets[] = { const std::string evil_targets[] = {
"/a\r\nInjected: pwned", "/a\r\nInjected: pwned",
"/a\rInjected", "/a\rInjected",
@@ -10231,9 +10230,9 @@ TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) { TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
// End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding // End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding
// disabled the client transmits the target verbatim, so a CR/LF-bearing // disabled the client transmits the target verbatim, so a CR/LF-bearing
// target -- what a redirect Location "%0D%0A" decodes to -- reaches // target reaches write_request. The client must fail cleanly with
// write_request. The client must fail cleanly with Error::Write instead of // Error::Write instead of putting a request-line-less, header-injecting
// putting a request-line-less, header-injecting request on the wire. // request on the wire.
Server svr; Server svr;
svr.Get("/a", [](const Request &, Response &res) { svr.Get("/a", [](const Request &, Response &res) {
@@ -17496,6 +17495,138 @@ TEST(TaskQueueTest, MaxQueuedRequests) {
#endif #endif
} }
TEST(RedirectTest, SeeOtherDoesNotResendContentProviderBody) {
Server svr;
std::string method;
std::string body;
auto has_content_length = false;
auto has_transfer_encoding = false;
std::atomic<int> bad_requests{0};
// Leftover body bytes get parsed as a malformed request and answered with
// 400
svr.set_logger([&](const Request & /*req*/, const Response &res) {
if (res.status == StatusCode::BadRequest_400) { bad_requests++; }
});
svr.Post("/up", [](const Request & /*req*/, Response &res) {
res.set_redirect("/down", StatusCode::SeeOther_303);
});
svr.Get("/down", [&](const Request &req, Response &res) {
method = req.method;
body = req.body;
has_content_length = req.has_header("Content-Length");
has_transfer_encoding = req.has_header("Transfer-Encoding");
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
const std::string payload = "SECRET-BODY";
auto check = [&](Client &cli, const Result &res) {
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("ok", res->body);
EXPECT_EQ("GET", method);
EXPECT_TRUE(body.empty());
EXPECT_FALSE(has_content_length);
EXPECT_FALSE(has_transfer_encoding);
// Nothing of the original body may be left on the connection
auto res2 = cli.Get("/down");
ASSERT_TRUE(res2) << "Error: " << to_string(res2.error());
EXPECT_EQ(StatusCode::OK_200, res2->status);
EXPECT_EQ("ok", res2->body);
EXPECT_EQ(0, bad_requests);
};
// With content length
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up", payload.size(),
[&](size_t offset, size_t length, DataSink &sink) {
return sink.write(payload.data() + offset, length);
},
"text/plain");
check(cli, res);
}
// Without content length (chunked)
{
Client cli(HOST, port);
cli.set_keep_alive(true);
cli.set_follow_location(true);
auto res = cli.Post(
"/up",
[&](size_t /*offset*/, DataSink &sink) {
sink.write(payload.data(), payload.size());
sink.done();
return true;
},
"text/plain");
check(cli, res);
}
}
TEST(RedirectTest, LocationPathIsNotDecoded) {
Server svr;
std::string target;
svr.Get(R"(/start/.*)", [](const Request &req, Response &res) {
// Echo the still-encoded name back in the Location
const std::string prefix = "/start/";
res.status = StatusCode::Found_302;
res.set_header("Location", "/dest/" + req.target.substr(prefix.size()));
});
svr.Get(R"(/dest.*)", [&](const Request &req, Response &res) {
target = req.target;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Decoding the Location path would turn the first four into a path
// separator, a query delimiter, a fragment delimiter and a different
// percent-encoded octet. The rest must keep reaching the server as they are.
const std::vector<std::string> names = {
"a%2Fb", "x%3Fy", "x%23y", "a%2520b", "a%20b", "%C3%BC", "a-b_c.d~e",
};
for (auto path_encode : {true, false}) {
Client cli(HOST, port);
cli.set_follow_location(true);
cli.set_path_encode(path_encode);
for (const auto &name : names) {
target.clear();
auto res = cli.Get("/start/" + name);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("/dest/" + name, target);
}
}
}
TEST(RedirectTest, RedirectToUrlWithQueryParameters) { TEST(RedirectTest, RedirectToUrlWithQueryParameters) {
Server svr; Server svr;