From e242c30573b6611f9c4357a11c0d668fd697636f Mon Sep 17 00:00:00 2001 From: Benjamin Sergeant Date: Wed, 14 Oct 2026 16:50:37 -0700 Subject: [PATCH] Accept LF-only HTTP headers with bounded line parsing Read header lines through LF and use the existing value trimming for CRLF. Reject lines that exceed the fixed buffer rather than parsing their remainder as another header. Add coverage for both line endings, empty blocks, body preservation, boundary lengths, EOF and cancellation. Based on flagarde's PR #603. Current master also contains the dependency removal that fixes that PR's stale Windows GCC configure failure. Co-authored-by: flagarde --- ixwebsocket/IXWebSocketHttpHeaders.cpp | 13 ++- test/CMakeLists.txt | 1 + test/IXWebSocketHttpHeadersTest.cpp | 131 +++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 test/IXWebSocketHttpHeadersTest.cpp diff --git a/ixwebsocket/IXWebSocketHttpHeaders.cpp b/ixwebsocket/IXWebSocketHttpHeaders.cpp index c6fbbf19..b9150a2e 100644 --- a/ixwebsocket/IXWebSocketHttpHeaders.cpp +++ b/ixwebsocket/IXWebSocketHttpHeaders.cpp @@ -21,21 +21,26 @@ namespace ix while (true) { - int colon = 0; + int colon = -1; - for (i = 0; i < 2 || (i < 1023 && line[i - 2] != '\r' && line[i - 1] != '\n'); ++i) + for (i = 0; i < 1023; ++i) { if (!socket->readByte(line + i, isCancellationRequested)) { return std::make_pair(false, headers); } - if (line[i] == ':' && colon == 0) + if (line[i] == '\n') break; + + if (line[i] == ':' && colon == -1) { colon = i; } } - if (line[0] == '\r' && line[1] == '\n') + // Reject oversized lines instead of parsing their remainder as another header. + if (i == 1023) return std::make_pair(false, headers); + + if (i == 0 || (i == 1 && line[0] == '\r')) { break; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4f14382b..d0fc879a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ set (TEST_TARGET_NAMES # IXHttpClientTest ## FIXME httpbin.org does not seem normal IXUnityBuildsTest IXHttpTest + IXWebSocketHttpHeadersTest IXDNSLookupTest IXWebSocketSubProtocolTest # IXWebSocketBroadcastTest ## FIXME was depending on cobra / take a broadcast server from ws diff --git a/test/IXWebSocketHttpHeadersTest.cpp b/test/IXWebSocketHttpHeadersTest.cpp new file mode 100644 index 00000000..d767e5a2 --- /dev/null +++ b/test/IXWebSocketHttpHeadersTest.cpp @@ -0,0 +1,131 @@ +/* + * IXWebSocketHttpHeadersTest.cpp + * Copyright (c) 2026 Machine Zone, Inc. All rights reserved. + */ + +#include +#include +#include +#include +#include + +namespace +{ + class HttpHeadersTestSocket final : public ix::Socket + { + public: + explicit HttpHeadersTestSocket(const std::string& input) + : _input(input) + , _offset(0) + { + } + + std::ptrdiff_t recv(void* buffer, size_t length) override + { + size_t count = std::min(length, _input.size() - _offset); + std::memcpy(buffer, _input.data() + _offset, count); + _offset += count; + return static_cast(count); + } + + private: + std::string _input; + size_t _offset; + }; +} // namespace + +TEST_CASE("HTTP header line endings", "[http][headers]") +{ + const auto ending = GENERATE(std::string("\r\n"), std::string("\n")); + CAPTURE(ending); + + std::unique_ptr socket(new HttpHeadersTestSocket( + "X-Test: value " + ending + "Location: http://localhost:8080/" + ending + + "Empty:" + ending + ": Invalid: ignored" + ending + ending + "BODY")); + auto result = ix::parseHttpHeaders(socket, [] { return false; }); + + REQUIRE(result.first); + REQUIRE(result.second.size() == 3); + CHECK(result.second.at("x-test") == "value"); + CHECK(result.second.at("Location") == "http://localhost:8080/"); + CHECK(result.second.at("Empty").empty()); + + char next; + REQUIRE(socket->readByte(&next, [] { return false; })); + CHECK(next == 'B'); +} + +TEST_CASE("Empty and mixed HTTP header blocks", "[http][headers]") +{ + std::string input; + size_t expectedHeaders = 0; + SECTION("Empty CRLF block") + { + input = "\r\nBODY"; + } + SECTION("Empty LF block") + { + input = "\nBODY"; + } + SECTION("Mixed line endings") + { + input = "First: one\r\nSecond: two\n\r\nBODY"; + expectedHeaders = 2; + } + + std::unique_ptr socket(new HttpHeadersTestSocket(input)); + auto result = ix::parseHttpHeaders(socket, [] { return false; }); + REQUIRE(result.first); + CHECK(result.second.size() == expectedHeaders); + + char next; + REQUIRE(socket->readByte(&next, [] { return false; })); + CHECK(next == 'B'); +} + +TEST_CASE("HTTP header line length limit", "[http][headers]") +{ + const auto ending = GENERATE(std::string("\r\n"), std::string("\n")); + CAPTURE(ending); + + const std::string value(1023 - 3 - ending.size(), 'a'); + std::unique_ptr socket(new HttpHeadersTestSocket("X: " + value + ending + ending)); + auto result = ix::parseHttpHeaders(socket, [] { return false; }); + REQUIRE(result.first); + CHECK(result.second.at("X") == value); + + socket.reset(new HttpHeadersTestSocket("X: " + value + "a" + ending + ending)); + CHECK_FALSE(ix::parseHttpHeaders(socket, [] { return false; }).first); + + socket.reset(new HttpHeadersTestSocket("X: " + std::string(1020, 'a') + "Injected: value" + + ending + ending)); + result = ix::parseHttpHeaders(socket, [] { return false; }); + CHECK_FALSE(result.first); + CHECK(result.second.count("Injected") == 0); +} + +TEST_CASE("Incomplete or cancelled HTTP headers", "[http][headers]") +{ + std::string input; + bool cancelled = false; + SECTION("EOF") + { + input = ""; + } + SECTION("Partial line") + { + input = "X: value"; + } + SECTION("Missing header block terminator") + { + input = "X: value\n"; + } + SECTION("Cancelled") + { + input = "X: value\n\n"; + cancelled = true; + } + + std::unique_ptr socket(new HttpHeadersTestSocket(input)); + CHECK_FALSE(ix::parseHttpHeaders(socket, [cancelled] { return cancelled; }).first); +}