Loading test/CMakeLists.txt +12 −0 Original line number Diff line number Diff line Loading @@ -129,3 +129,15 @@ else() endif() add_test(httpcompression_test httpcompression_test) add_executable(http_cookie_headers_test http_cookie_headers_test.cpp ) if(WIN32) target_link_libraries (http_cookie_headers_test httppp-static ws2_32) else() target_link_libraries (http_cookie_headers_test httppp-static) endif() add_test(http_cookie_headers_test http_cookie_headers_test) test/http_cookie_headers_test.cpp 0 → 100644 +340 −0 Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Regression test for a chain of HTTP/1.1 header bugs that broke cookies // end-to-end through a reverse proxy (browser -> H2 proxy -> H1 upstream and // back): // // 1. HttpHeader::setHeaderData() clobbers (clears, instead of appending to) // any header key it is called with more than once. HttpRequest::parseH1() // and HttpResponse::parse() both called it unconditionally per physical // header line, so a client/upstream that sent the same header name on two // separate lines (e.g. two Set-Cookie: lines, or two Cookie: lines) only // ever had the LAST line's value survive. // 2. HttpResponse::parse() additionally comma-split every header value // generically (for legitimate list headers like Accept/TE), which // corrupts any Set-Cookie whose Expires attribute contains a comma // (e.g. "Expires=Wed, 21 Oct 2026 07:28:00 GMT"). // 3. HttpRequest::printHeader() serialized a Cookie header holding multiple // Values (the shape parseH2()/parseH3() produce from a real browser's // single "Cookie: a=1; b=2" H2/H3 field, split on ';' per RFC 7541/9114) // as one physical "Cookie:" line PER value, instead of the single // "; "-joined line RFC 6265 Sec5.4 requires -- so relaying a multi-cookie // H2 request to an H1 upstream re-triggered bug #1 on the upstream's own // parser, losing all but the last cookie pair. // // Drives real HttpRequest/HttpResponse parse+serialize round trips over real // sockets (not mocks) to pin all three fixes down. #include <chrono> #include <iostream> #include <memory> #include <sstream> #include <stdexcept> #include <string> #include <thread> #include <vector> #include <netplus/exception.h> #include <netplus/socket.h> #include "http.h" #include "httpd.h" #include "raw_tcp_client.h" using namespace libhttppp; namespace { int g_passed = 0, g_failed = 0; void check(bool ok, const std::string &name) { if (ok) { std::cout << " PASS: " << name << std::endl; ++g_passed; } else { std::cout << " FAIL: " << name << std::endl; ++g_failed; } } // GET /cookie-echo -> echoes the values seen on the incoming Cookie // header back as x-cookie-count/x-cookie-echo. // GET /set-two-cookies -> replies with two genuinely separate Set-Cookie: // lines, one holding a comma in its Expires attr. class CookieEvent : public HttpEvent { public: explicit CookieEvent(std::vector<netplus::socket *> serversocket) : HttpEvent(serversocket) {} void RequestEvent(HttpRequest &req, const int, ULONG_PTR) override { const std::string &url = req.getRequestURL(); if (url == "/cookie-echo") { std::vector<std::string> values; if (auto *hd = req.getHeaderData("cookie")) { for (auto *v = hd->getfirstValue(); v; v = v->nextvalue()) values.push_back(v->getvalue()); } std::string joined; for (size_t i = 0; i < values.size(); ++i) { if (i) joined += "|"; joined += values[i]; } HttpResponse res; res.setHeaderData("x-cookie-count")->push_back(std::to_string(values.size())); res.setHeaderData("x-cookie-echo")->push_back(joined); res.send(req, std::string("ok"), 2); return; } if (url == "/set-two-cookies") { HttpResponse res; auto *sc = res.setHeaderData("set-cookie"); sc->push_back("consent=yes; Path=/"); sc->push_back("session=abc123; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"); res.send(req, std::string("ok"), 2); return; } HttpResponse res; res.setState("404 Not Found"); res.send(req, std::string("not found"), 9); } }; struct RunningServer { std::unique_ptr<netplus::tcp> sock; std::unique_ptr<CookieEvent> server; std::thread thread; RunningServer() = default; RunningServer(RunningServer &&) = default; RunningServer &operator=(RunningServer &&) = default; ~RunningServer() { if (thread.joinable()) { netplus::event::Running = false; thread.join(); } } }; RunningServer startServer(int &port) { RunningServer rs; rs.sock = std::make_unique<netplus::tcp>("127.0.0.1", 0, 16, -1); rs.sock->bind(); port = rawtcp::boundPort(*rs.sock); rs.sock->listen(); rs.server = std::make_unique<CookieEvent>(std::vector<netplus::socket *>{rs.sock.get()}); netplus::event::Running = true; CookieEvent *serverPtr = rs.server.get(); rs.thread = std::thread([serverPtr] { serverPtr->runEventloop(); }); std::this_thread::sleep_for(std::chrono::milliseconds(50)); return rs; } // Reads one non-chunked HTTP/1.1 response off a raw socket via the library's // own HttpResponse::parse(), so the parsing logic under test is the real // thing (matches http1_checksum_test.cpp's rawReadResponse). void rawReadHeadersAndParse(netplus::socket &sock, HttpResponse &res) { auto recvSome = [&](std::vector<char> &dst) -> size_t { netplus::buffer buf(8192); size_t n; try { n = sock.recvData(buf); } catch (netplus::NetException &) { return 0; } dst.insert(dst.end(), buf.data.buf, buf.data.buf + n); return n; }; std::vector<char> raw; size_t header_end = std::string::npos; for (;;) { if (raw.size() >= 4) { for (size_t i = 0; i + 3 < raw.size(); ++i) { if (raw[i] == '\r' && raw[i + 1] == '\n' && raw[i + 2] == '\r' && raw[i + 3] == '\n') { header_end = i + 4; break; } } } if (header_end != std::string::npos) break; if (recvSome(raw) == 0) throw std::runtime_error("raw recv: EOF before headers complete"); } size_t parsed = res.parse(raw.data(), raw.size()); size_t contentLength = res.getContentLength(); std::vector<char> body(raw.begin() + static_cast<ptrdiff_t>(parsed), raw.end()); while (body.size() < contentLength) { if (recvSome(body) == 0) throw std::runtime_error("raw recv: EOF before body complete"); } } std::string headerValue(const HttpResponse &res, const std::string &key) { auto *hd = res.getHeaderData(key); return (hd && hd->getfirstValue()) ? hd->getfirstValue()->getvalue() : std::string(); } // A client sending a Cookie header as two separate physical lines (the wire // shape the pre-fix printHeader() produced when relaying a multi-cookie H2 // request to an H1 upstream) must have BOTH values reach RequestEvent, not // just the last one -- exercises HttpRequest::parseH1()'s fix. void testDuplicatePhysicalCookieLinesAppend(int port) { auto sock = std::make_unique<netplus::tcp>(); rawtcp::connectLocal(*sock, port); std::ostringstream req; req << "GET /cookie-echo HTTP/1.1\r\n" << "Host: 127.0.0.1:" << port << "\r\n" << "Cookie: consent=yes\r\n" << "Cookie: session=abc123\r\n" << "Connection: close\r\n" << "\r\n"; rawtcp::sendAllOrThrow(*sock, req.str()); HttpResponse res; rawReadHeadersAndParse(*sock, res); sock->close(); check(headerValue(res, "x-cookie-count") == "2", "duplicate Cookie: lines -- server sees both values, not just the last"); check(headerValue(res, "x-cookie-echo") == "consent=yes|session=abc123", "duplicate Cookie: lines -- both exact values preserved, in order"); } // HttpRequest::printHeader() must serialize a Cookie header holding multiple // Values as ONE "; "-joined line, not one physical line per value -- exercises // the printHeader() fix directly by inspecting the exact bytes put on the wire. void testPrintHeaderJoinsCookieOntoOneLine() { auto listener = std::make_unique<netplus::tcp>("127.0.0.1", 0, 1, -1); listener->bind(); int listenPort = rawtcp::boundPort(*listener); listener->listen(); std::string captured; std::thread serverThread([&]{ std::unique_ptr<netplus::socket> csock = std::make_unique<netplus::tcp>(); try { listener->accept(csock, /*nonblock=*/false); } catch (netplus::NetException &) { return; } std::vector<char> raw; for (;;) { std::string seen(raw.begin(), raw.end()); if (seen.find("\r\n\r\n") != std::string::npos) break; netplus::buffer buf(4096); size_t n; try { n = csock->recvData(buf); } catch (netplus::NetException &) { break; } if (n == 0) break; raw.insert(raw.end(), buf.data.buf, buf.data.buf + n); } captured.assign(raw.begin(), raw.end()); rawtcp::sendAllOrThrow(*csock, std::string("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")); csock->close(); }); { HttpUrl url("http://127.0.0.1:" + std::to_string(listenPort) + "/x"); HttpClient clt(url, /*vers=*/0, /*timeoutSec=*/5); HttpRequest req; req.setRequestURL("/x"); // Simulates what parseH2/parseH3 store after splitting a real // browser's single "Cookie: consent=yes; session=abc123" H2 field. auto *cookie = req.setHeaderData("cookie"); cookie->push_back("consent=yes"); cookie->push_back("session=abc123"); clt.Get(req, /*maxTries=*/1); } serverThread.join(); // The library always serializes stored (already-lowercased) header keys // as-is -- HTTP header names are case-insensitive (RFC 7230 Sec3.2), so // "cookie:" on the wire is equivalent to "Cookie:". size_t lineCount = 0, pos = 0; while ((pos = captured.find("cookie:", pos)) != std::string::npos) { ++lineCount; pos += 7; } check(lineCount == 1, "outgoing request has exactly one cookie: line"); check(captured.find("cookie: consent=yes; session=abc123\r\n") != std::string::npos, "outgoing cookie: line joins both pairs with \"; \" on one line"); } // Two genuinely repeated Set-Cookie: lines from a real upstream response, one // containing a comma inside its Expires attribute, must both reach the client // intact -- exercises HttpResponse::parse()'s append + no-comma-split fixes. void testResponseSetCookiePreserved(int port) { HttpUrl url("http://127.0.0.1:" + std::to_string(port) + "/set-two-cookies"); HttpClient clt(url, /*vers=*/0, /*timeoutSec=*/5); HttpRequest req; req.setRequestURL("/set-two-cookies"); clt.Get(req, /*maxTries=*/3); std::vector<std::string> values; if (auto *hd = clt.lastResponse()->getHeaderData("set-cookie")) { for (auto *v = hd->getfirstValue(); v; v = v->nextvalue()) values.push_back(v->getvalue()); } check(values.size() == 2, "two Set-Cookie: lines both survive parsing (not clobbered)"); check(values.size() >= 1 && values[0] == "consent=yes; Path=/", "first Set-Cookie value intact"); check(values.size() >= 2 && values[1] == "session=abc123; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/", "second Set-Cookie value intact, not comma-split at its Expires date"); } } // namespace int main() { std::cout << "=== HTTP Cookie Header Handling Test ===" << std::endl; int rc = 0; try { int port = 0; RunningServer rs = startServer(port); std::cout << "Server listening on 127.0.0.1:" << port << std::endl; testDuplicatePhysicalCookieLinesAppend(port); testPrintHeaderJoinsCookieOntoOneLine(); testResponseSetCookiePreserved(port); } catch (HTTPException &e) { std::cerr << "HTTPException: " << e.what() << std::endl; rc = 1; } catch (netplus::NetException &e) { std::cerr << "NetException: " << e.what() << std::endl; rc = 1; } catch (std::exception &e) { std::cerr << "Exception: " << e.what() << std::endl; rc = 1; } std::cout << "==============================" << std::endl; std::cout << "Results: " << g_passed << " passed, " << g_failed << " failed" << std::endl; std::cout << "==============================" << std::endl; return (rc != 0 || g_failed > 0) ? 1 : 0; } Loading
test/CMakeLists.txt +12 −0 Original line number Diff line number Diff line Loading @@ -129,3 +129,15 @@ else() endif() add_test(httpcompression_test httpcompression_test) add_executable(http_cookie_headers_test http_cookie_headers_test.cpp ) if(WIN32) target_link_libraries (http_cookie_headers_test httppp-static ws2_32) else() target_link_libraries (http_cookie_headers_test httppp-static) endif() add_test(http_cookie_headers_test http_cookie_headers_test)
test/http_cookie_headers_test.cpp 0 → 100644 +340 −0 Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Regression test for a chain of HTTP/1.1 header bugs that broke cookies // end-to-end through a reverse proxy (browser -> H2 proxy -> H1 upstream and // back): // // 1. HttpHeader::setHeaderData() clobbers (clears, instead of appending to) // any header key it is called with more than once. HttpRequest::parseH1() // and HttpResponse::parse() both called it unconditionally per physical // header line, so a client/upstream that sent the same header name on two // separate lines (e.g. two Set-Cookie: lines, or two Cookie: lines) only // ever had the LAST line's value survive. // 2. HttpResponse::parse() additionally comma-split every header value // generically (for legitimate list headers like Accept/TE), which // corrupts any Set-Cookie whose Expires attribute contains a comma // (e.g. "Expires=Wed, 21 Oct 2026 07:28:00 GMT"). // 3. HttpRequest::printHeader() serialized a Cookie header holding multiple // Values (the shape parseH2()/parseH3() produce from a real browser's // single "Cookie: a=1; b=2" H2/H3 field, split on ';' per RFC 7541/9114) // as one physical "Cookie:" line PER value, instead of the single // "; "-joined line RFC 6265 Sec5.4 requires -- so relaying a multi-cookie // H2 request to an H1 upstream re-triggered bug #1 on the upstream's own // parser, losing all but the last cookie pair. // // Drives real HttpRequest/HttpResponse parse+serialize round trips over real // sockets (not mocks) to pin all three fixes down. #include <chrono> #include <iostream> #include <memory> #include <sstream> #include <stdexcept> #include <string> #include <thread> #include <vector> #include <netplus/exception.h> #include <netplus/socket.h> #include "http.h" #include "httpd.h" #include "raw_tcp_client.h" using namespace libhttppp; namespace { int g_passed = 0, g_failed = 0; void check(bool ok, const std::string &name) { if (ok) { std::cout << " PASS: " << name << std::endl; ++g_passed; } else { std::cout << " FAIL: " << name << std::endl; ++g_failed; } } // GET /cookie-echo -> echoes the values seen on the incoming Cookie // header back as x-cookie-count/x-cookie-echo. // GET /set-two-cookies -> replies with two genuinely separate Set-Cookie: // lines, one holding a comma in its Expires attr. class CookieEvent : public HttpEvent { public: explicit CookieEvent(std::vector<netplus::socket *> serversocket) : HttpEvent(serversocket) {} void RequestEvent(HttpRequest &req, const int, ULONG_PTR) override { const std::string &url = req.getRequestURL(); if (url == "/cookie-echo") { std::vector<std::string> values; if (auto *hd = req.getHeaderData("cookie")) { for (auto *v = hd->getfirstValue(); v; v = v->nextvalue()) values.push_back(v->getvalue()); } std::string joined; for (size_t i = 0; i < values.size(); ++i) { if (i) joined += "|"; joined += values[i]; } HttpResponse res; res.setHeaderData("x-cookie-count")->push_back(std::to_string(values.size())); res.setHeaderData("x-cookie-echo")->push_back(joined); res.send(req, std::string("ok"), 2); return; } if (url == "/set-two-cookies") { HttpResponse res; auto *sc = res.setHeaderData("set-cookie"); sc->push_back("consent=yes; Path=/"); sc->push_back("session=abc123; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"); res.send(req, std::string("ok"), 2); return; } HttpResponse res; res.setState("404 Not Found"); res.send(req, std::string("not found"), 9); } }; struct RunningServer { std::unique_ptr<netplus::tcp> sock; std::unique_ptr<CookieEvent> server; std::thread thread; RunningServer() = default; RunningServer(RunningServer &&) = default; RunningServer &operator=(RunningServer &&) = default; ~RunningServer() { if (thread.joinable()) { netplus::event::Running = false; thread.join(); } } }; RunningServer startServer(int &port) { RunningServer rs; rs.sock = std::make_unique<netplus::tcp>("127.0.0.1", 0, 16, -1); rs.sock->bind(); port = rawtcp::boundPort(*rs.sock); rs.sock->listen(); rs.server = std::make_unique<CookieEvent>(std::vector<netplus::socket *>{rs.sock.get()}); netplus::event::Running = true; CookieEvent *serverPtr = rs.server.get(); rs.thread = std::thread([serverPtr] { serverPtr->runEventloop(); }); std::this_thread::sleep_for(std::chrono::milliseconds(50)); return rs; } // Reads one non-chunked HTTP/1.1 response off a raw socket via the library's // own HttpResponse::parse(), so the parsing logic under test is the real // thing (matches http1_checksum_test.cpp's rawReadResponse). void rawReadHeadersAndParse(netplus::socket &sock, HttpResponse &res) { auto recvSome = [&](std::vector<char> &dst) -> size_t { netplus::buffer buf(8192); size_t n; try { n = sock.recvData(buf); } catch (netplus::NetException &) { return 0; } dst.insert(dst.end(), buf.data.buf, buf.data.buf + n); return n; }; std::vector<char> raw; size_t header_end = std::string::npos; for (;;) { if (raw.size() >= 4) { for (size_t i = 0; i + 3 < raw.size(); ++i) { if (raw[i] == '\r' && raw[i + 1] == '\n' && raw[i + 2] == '\r' && raw[i + 3] == '\n') { header_end = i + 4; break; } } } if (header_end != std::string::npos) break; if (recvSome(raw) == 0) throw std::runtime_error("raw recv: EOF before headers complete"); } size_t parsed = res.parse(raw.data(), raw.size()); size_t contentLength = res.getContentLength(); std::vector<char> body(raw.begin() + static_cast<ptrdiff_t>(parsed), raw.end()); while (body.size() < contentLength) { if (recvSome(body) == 0) throw std::runtime_error("raw recv: EOF before body complete"); } } std::string headerValue(const HttpResponse &res, const std::string &key) { auto *hd = res.getHeaderData(key); return (hd && hd->getfirstValue()) ? hd->getfirstValue()->getvalue() : std::string(); } // A client sending a Cookie header as two separate physical lines (the wire // shape the pre-fix printHeader() produced when relaying a multi-cookie H2 // request to an H1 upstream) must have BOTH values reach RequestEvent, not // just the last one -- exercises HttpRequest::parseH1()'s fix. void testDuplicatePhysicalCookieLinesAppend(int port) { auto sock = std::make_unique<netplus::tcp>(); rawtcp::connectLocal(*sock, port); std::ostringstream req; req << "GET /cookie-echo HTTP/1.1\r\n" << "Host: 127.0.0.1:" << port << "\r\n" << "Cookie: consent=yes\r\n" << "Cookie: session=abc123\r\n" << "Connection: close\r\n" << "\r\n"; rawtcp::sendAllOrThrow(*sock, req.str()); HttpResponse res; rawReadHeadersAndParse(*sock, res); sock->close(); check(headerValue(res, "x-cookie-count") == "2", "duplicate Cookie: lines -- server sees both values, not just the last"); check(headerValue(res, "x-cookie-echo") == "consent=yes|session=abc123", "duplicate Cookie: lines -- both exact values preserved, in order"); } // HttpRequest::printHeader() must serialize a Cookie header holding multiple // Values as ONE "; "-joined line, not one physical line per value -- exercises // the printHeader() fix directly by inspecting the exact bytes put on the wire. void testPrintHeaderJoinsCookieOntoOneLine() { auto listener = std::make_unique<netplus::tcp>("127.0.0.1", 0, 1, -1); listener->bind(); int listenPort = rawtcp::boundPort(*listener); listener->listen(); std::string captured; std::thread serverThread([&]{ std::unique_ptr<netplus::socket> csock = std::make_unique<netplus::tcp>(); try { listener->accept(csock, /*nonblock=*/false); } catch (netplus::NetException &) { return; } std::vector<char> raw; for (;;) { std::string seen(raw.begin(), raw.end()); if (seen.find("\r\n\r\n") != std::string::npos) break; netplus::buffer buf(4096); size_t n; try { n = csock->recvData(buf); } catch (netplus::NetException &) { break; } if (n == 0) break; raw.insert(raw.end(), buf.data.buf, buf.data.buf + n); } captured.assign(raw.begin(), raw.end()); rawtcp::sendAllOrThrow(*csock, std::string("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")); csock->close(); }); { HttpUrl url("http://127.0.0.1:" + std::to_string(listenPort) + "/x"); HttpClient clt(url, /*vers=*/0, /*timeoutSec=*/5); HttpRequest req; req.setRequestURL("/x"); // Simulates what parseH2/parseH3 store after splitting a real // browser's single "Cookie: consent=yes; session=abc123" H2 field. auto *cookie = req.setHeaderData("cookie"); cookie->push_back("consent=yes"); cookie->push_back("session=abc123"); clt.Get(req, /*maxTries=*/1); } serverThread.join(); // The library always serializes stored (already-lowercased) header keys // as-is -- HTTP header names are case-insensitive (RFC 7230 Sec3.2), so // "cookie:" on the wire is equivalent to "Cookie:". size_t lineCount = 0, pos = 0; while ((pos = captured.find("cookie:", pos)) != std::string::npos) { ++lineCount; pos += 7; } check(lineCount == 1, "outgoing request has exactly one cookie: line"); check(captured.find("cookie: consent=yes; session=abc123\r\n") != std::string::npos, "outgoing cookie: line joins both pairs with \"; \" on one line"); } // Two genuinely repeated Set-Cookie: lines from a real upstream response, one // containing a comma inside its Expires attribute, must both reach the client // intact -- exercises HttpResponse::parse()'s append + no-comma-split fixes. void testResponseSetCookiePreserved(int port) { HttpUrl url("http://127.0.0.1:" + std::to_string(port) + "/set-two-cookies"); HttpClient clt(url, /*vers=*/0, /*timeoutSec=*/5); HttpRequest req; req.setRequestURL("/set-two-cookies"); clt.Get(req, /*maxTries=*/3); std::vector<std::string> values; if (auto *hd = clt.lastResponse()->getHeaderData("set-cookie")) { for (auto *v = hd->getfirstValue(); v; v = v->nextvalue()) values.push_back(v->getvalue()); } check(values.size() == 2, "two Set-Cookie: lines both survive parsing (not clobbered)"); check(values.size() >= 1 && values[0] == "consent=yes; Path=/", "first Set-Cookie value intact"); check(values.size() >= 2 && values[1] == "session=abc123; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/", "second Set-Cookie value intact, not comma-split at its Expires date"); } } // namespace int main() { std::cout << "=== HTTP Cookie Header Handling Test ===" << std::endl; int rc = 0; try { int port = 0; RunningServer rs = startServer(port); std::cout << "Server listening on 127.0.0.1:" << port << std::endl; testDuplicatePhysicalCookieLinesAppend(port); testPrintHeaderJoinsCookieOntoOneLine(); testResponseSetCookiePreserved(port); } catch (HTTPException &e) { std::cerr << "HTTPException: " << e.what() << std::endl; rc = 1; } catch (netplus::NetException &e) { std::cerr << "NetException: " << e.what() << std::endl; rc = 1; } catch (std::exception &e) { std::cerr << "Exception: " << e.what() << std::endl; rc = 1; } std::cout << "==============================" << std::endl; std::cout << "Results: " << g_passed << " passed, " << g_failed << " failed" << std::endl; std::cout << "==============================" << std::endl; return (rc != 0 || g_failed > 0) ? 1 : 0; }