Reject CR/LF in the request target and fail the request cleanly (#2501)

* reject crlf in request target in write_request_line

* declare write_request_line in test.cc for split builds

split.py strips `inline` and moves the definition into httplib.cc, so
detail::write_request_line is not visible from the split httplib.h and
test_split failed to compile. Re-declare it in test.cc, matching what the
base64_encode and getaddrinfo_with_timeout tests already do.

* Fail the request when write_request_line rejects the target

The CR/LF guard in write_request_line returns -1, but ClientImpl::write_request
ignored that return value. A rejected target therefore produced a request-line-
less request (headers only) that the client silently reported as a successful
send. This is the primary path reachable via a decoded redirect Location under
set_path_encode(false), and it also carries the CONNECT target.

Check the return value like ClientImpl::open_stream already does and fail with
Error::Write. Add an end-to-end test asserting the client refuses a CR/LF target
instead of putting it on the wire.

---------

Co-authored-by: Sayed Kaif <metsw24@gmail.com>
This commit is contained in:
yhirose
2026-07-18 18:07:44 -04:00
committed by GitHub
co-authored by Sayed Kaif
parent 75938f08c7
commit 255c075b82
2 changed files with 89 additions and 1 deletions
+74
View File
@@ -7938,6 +7938,80 @@ TEST(ServerResponseSplittingTest, ChunkedTrailerCRLFInjection) {
EXPECT_NE(std::string::npos, res.find("X-Safe: safe"));
}
// Forward declaration: in split builds split.py strips `inline` and moves the
// definition into httplib.cc, so detail::write_request_line is not visible from
// the public httplib.h. Re-declaring it here lets the tests link against the
// symbol in both header-only and split builds.
namespace httplib {
namespace detail {
ssize_t write_request_line(Stream &strm, const std::string &method,
const std::string &path);
} // namespace detail
} // namespace httplib
TEST(RequestLineInjectionTest, RejectsCRLFInTarget) {
// A well-formed target is written verbatim.
{
detail::BufferStream strm;
auto n = detail::write_request_line(strm, "GET", "/path?a=b");
EXPECT_GT(n, 0);
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.
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",
};
for (const auto &evil : evil_targets) {
detail::BufferStream strm;
auto n = detail::write_request_line(strm, "GET", evil);
EXPECT_LT(n, 0);
EXPECT_TRUE(strm.get_buffer().empty());
}
}
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.
Server svr;
svr.Get("/a", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto thread = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, PORT);
cli.set_path_encode(false);
// The request is rejected before anything is written, so the connection
// stays silent and the subsequent response read would otherwise block for
// the full default read timeout. Shorten it -- the assertion is on the
// error, not the timeout.
cli.set_read_timeout(1, 0);
auto res = cli.Get("/a\r\nInjected: pwned");
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
}
}
// Sends a raw request and verifies that there isn't a crash or exception.
static void test_raw_request(const std::string &req,
std::string *out = nullptr) {