Commit f89a54a2 authored by jan.koester's avatar jan.koester
Browse files

test

parent cbcea9b4
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -141,3 +141,15 @@ else()
endif()

add_test(http_cookie_headers_test http_cookie_headers_test)

add_executable(http_useragent_header_test
    http_useragent_header_test.cpp
)

if(WIN32)
    target_link_libraries (http_useragent_header_test httppp-static ws2_32)
else()
    target_link_libraries (http_useragent_header_test httppp-static)
endif()

add_test(http_useragent_header_test http_useragent_header_test)
+294 −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 HttpRequest::parseH1()/parseH2()/parseH3() blanket-
// splitting every header value on ';'. That logic exists so a single Cookie
// line's several "name=value" pairs (RFC 6265 Sec4.2.1) land as separate
// Values, but it was applied to every header, not just Cookie. A real
// browser User-Agent is full of semicolons ("Mozilla/5.0 (Windows NT 10.0;
// Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/... Safari/...")
// so it got shredded into several bogus fragments under one "user-agent"
// key. HttpRequest::printHeader() then serialized each fragment as its own
// physical "User-Agent:" line, so any reverse proxy relaying the parsed
// request upstream sent a request with several literal duplicate
// User-Agent headers -- rejected outright by strict upstreams/HTTP-2 stacks
// (RFC 7540 Sec8.1.2.2 forbids duplicate non-list singleton headers).
//
// Drives real HttpRequest parse+serialize round trips over real sockets
// (not mocks), the same way http_cookie_headers_test.cpp pins down the
// sibling Cookie-handling bugs.

#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; }
}

const char *kBrowserUA =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";

// GET /ua-echo -> echoes the number of Values and the joined value seen on
// the incoming User-Agent header back as x-ua-count/x-ua-echo.
class UAEvent : public HttpEvent {
public:
    explicit UAEvent(std::vector<netplus::socket *> serversocket)
        : HttpEvent(serversocket) {}

    void RequestEvent(HttpRequest &req, const int, ULONG_PTR) override {
        const std::string &url = req.getRequestURL();

        if (url == "/ua-echo") {
            std::vector<std::string> values;
            if (auto *hd = req.getHeaderData("user-agent")) {
                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-ua-count")->push_back(std::to_string(values.size()));
            res.setHeaderData("x-ua-echo")->push_back(joined);
            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<UAEvent> 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<UAEvent>(std::vector<netplus::socket *>{rs.sock.get()});
    netplus::event::Running = true;
    UAEvent *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 http_cookie_headers_test.cpp's rawReadHeadersAndParse).
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 real, semicolon-heavy browser User-Agent sent as ONE physical header
// line must reach RequestEvent as ONE Value, not shredded on every ';' --
// exercises HttpRequest::parseH1()'s fix.
void testSemicolonHeavyUserAgentNotSplit(int port) {
    auto sock = std::make_unique<netplus::tcp>();
    rawtcp::connectLocal(*sock, port);

    std::ostringstream req;
    req << "GET /ua-echo HTTP/1.1\r\n"
        << "Host: 127.0.0.1:" << port << "\r\n"
        << "User-Agent: " << kBrowserUA << "\r\n"
        << "Connection: close\r\n"
        << "\r\n";
    rawtcp::sendAllOrThrow(*sock, req.str());

    HttpResponse res;
    rawReadHeadersAndParse(*sock, res);
    sock->close();

    check(headerValue(res, "x-ua-count") == "1",
          "semicolon-heavy User-Agent -- server sees exactly one Value, not shredded");
    check(headerValue(res, "x-ua-echo") == kBrowserUA,
          "semicolon-heavy User-Agent -- exact original string preserved");
}

// HttpRequest::printHeader() must serialize a User-Agent header as ONE
// physical line, byte-for-byte -- the wire-level check that a proxy
// relaying this request upstream sends exactly one User-Agent, not several
// literal duplicates that a strict HTTP/2 upstream would reject outright.
void testPrintHeaderSingleUserAgentLine() {
    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 a reverse proxy forwarding what parseH1() stored for a
        // real client's single "User-Agent: ..." line.
        req.setHeaderData("user-agent")->push_back(kBrowserUA);
        clt.Get(req, /*maxTries=*/1);
    }
    serverThread.join();

    size_t lineCount = 0, pos = 0;
    while ((pos = captured.find("user-agent:", pos)) != std::string::npos) {
        ++lineCount;
        pos += 11;
    }
    check(lineCount == 1, "outgoing request has exactly one user-agent: line");
    check(captured.find(std::string("user-agent: ") + kBrowserUA + "\r\n") != std::string::npos,
          "outgoing user-agent: line carries the exact original value, unsplit");
}

} // namespace

int main() {
    std::cout << "=== HTTP User-Agent 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;

        testSemicolonHeavyUserAgentNotSplit(port);
        testPrintHeaderSingleUserAgentLine();

    } 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;
}