Apply the request-target check to the client and encode control chars

Share the server's request-target check as fields::is_request_target()
and use it in write_request_line too. The client previously used
is_field_value(), which let an embedded SP or HTAB through.

encode_path() only escaped CR/LF among the control characters, so with
path encoding enabled a path like "/a\tb" would now be rejected instead
of sent. Percent-encode every control character (0x00-0x1F, 0x7F).
This commit is contained in:
yhirose
2026-09-28 03:37:11 -04:00
parent e11dbec7b3
commit c1c2b1f4b4
2 changed files with 59 additions and 26 deletions
+44 -10
View File
@@ -4060,6 +4060,37 @@ TEST(PathUrlEncodeTest, StreamingCRLFInTargetIsEncoded) {
}
}
TEST(PathUrlEncodeTest, ControlCharsInPathAreEncoded) {
// Every control character is percent-encoded, not just CR/LF, so the target
// passes the request-target check in write_request_line.
Server svr;
std::string target;
svr.set_pre_routing_handler([&](const Request &req, Response &res) {
target = req.target;
res.status = StatusCode::OK_200;
return Server::HandlerResponse::Handled;
});
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();
{
Client cli(HOST, port);
auto res = cli.Get("/a\tb\x1b\x7f");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ("/a%09b%1B%7F", target);
}
}
TEST(PathUrlEncodeTest, StreamingCRLFRejectedWhenPathEncodeDisabled) {
// Nothing may reach the wire: a raw CR/LF target would split the request
// line and inject headers.
@@ -10092,7 +10123,7 @@ ssize_t write_request_line(Stream &strm, const std::string &method,
} // namespace detail
} // namespace httplib
TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
TEST(RequestLineInjectionTest, RejectsInvalidCharsInTarget) {
// A well-formed target is written verbatim.
{
detail::BufferStream strm;
@@ -10101,15 +10132,18 @@ TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
EXPECT_EQ("GET /path?a=b HTTP/1.1\r\n", strm.get_buffer());
}
// A target carrying CR/LF must be rejected 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 Location ("%0D%0A") turns into
// when path encoding is disabled.
// 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
// injects a header or a whole request. This is what a decoded redirect
// Location ("%0D%0A") turns into when path encoding is disabled.
const std::string evil_targets[] = {
"/a\r\nInjected: pwned",
"/a\rInjected",
"/a\nInjected",
"/a\r\n\r\nGET /evil HTTP/1.1\r\nHost: victim\r\n\r\n",
"/a b",
"/a\tb",
"/a\x7f",
};
for (const auto &evil : evil_targets) {
detail::BufferStream strm;
@@ -10120,11 +10154,11 @@ TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
}
TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
// End-to-end counterpart to RejectsCRLFInTarget. With path encoding disabled
// the client transmits the target verbatim, so a CR/LF-bearing target -- what
// a redirect Location "%0D%0A" decodes to -- reaches write_request. The
// client must fail cleanly with Error::Write instead of putting a
// request-line-less, header-injecting request on the wire.
// End-to-end counterpart to RejectsInvalidCharsInTarget. With path encoding
// disabled the client transmits the target verbatim, so a CR/LF-bearing
// target -- what a redirect Location "%0D%0A" decodes to -- reaches
// write_request. The client must fail cleanly with Error::Write instead of
// putting a request-line-less, header-injecting request on the wire.
Server svr;
svr.Get("/a", [](const Request &, Response &res) {