From e300c0d52b7b6a2699771e505a73a16693763e83 Mon Sep 17 00:00:00 2001 From: Enrique Wood Date: Sat, 14 Nov 2026 13:20:56 -0300 Subject: [PATCH] fix(nvhttp): bound GameStream requests before routing --- src/http_server.h | 27 ++++++ src/nvhttp.cpp | 3 + tests/unit/test_http_request_limits.cpp | 124 ++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/http_server.h create mode 100644 tests/unit/test_http_request_limits.cpp diff --git a/src/http_server.h b/src/http_server.h new file mode 100644 index 00000000000..5c28a326d4e --- /dev/null +++ b/src/http_server.h @@ -0,0 +1,27 @@ +/** + * @file src/http_server.h + * @brief Shared request limits for the GameStream HTTP servers. + */ +#pragma once + +// standard includes +#include + +namespace http { + constexpr std::size_t MAX_GAMESTREAM_REQUEST_SIZE = 64 * 1024; ///< Maximum GameStream request size in bytes. + + /** + * @brief Bound GameStream requests before routing or authentication. + * @details GameStream requests use the query string, including the pairing certificate, and have no body. + * The error handler ends a connection that fails while it is read; the server calls it without checking it is set + * when a chunked body is cut short. + * @param server HTTP or HTTPS server that handles GameStream requests. + */ + template + void configure_gamestream_server(Server &server) { + server.config.max_request_streambuf_size = MAX_GAMESTREAM_REQUEST_SIZE; + server.on_error = [](auto, const auto &) { + // Nothing to report: the connection is closed with its session. + }; + } +} // namespace http diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bf1bf6a6c80..4d781f287a9 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -28,6 +28,7 @@ #include "display_device.h" #include "file_handler.h" #include "globals.h" +#include "http_server.h" #include "httpcommon.h" #include "logging.h" #include "network.h" @@ -1662,6 +1663,8 @@ namespace nvhttp { https_server_t https_server {config::nvhttp.cert, config::nvhttp.pkey}; http_server_t http_server; + http::configure_gamestream_server(https_server); + http::configure_gamestream_server(http_server); // Verify certificates after establishing connection https_server.verify = [](SSL *ssl) { diff --git a/tests/unit/test_http_request_limits.cpp b/tests/unit/test_http_request_limits.cpp new file mode 100644 index 00000000000..69ff41f9331 --- /dev/null +++ b/tests/unit/test_http_request_limits.cpp @@ -0,0 +1,124 @@ +/** + * @file tests/unit/test_http_request_limits.cpp + * @brief Regressions for the GameStream request limits applied before routing or authentication. + */ + +// test includes +#include "../tests_common.h" + +// standard includes +#include +#include +#include + +// lib includes +#include + +// local includes +#include + +namespace { + using namespace std::chrono_literals; + using boost::asio::ip::tcp; + + /** + * @brief Exercise the actual HTTP reader on an isolated loopback listener. + */ + struct HttpRequestLimitsTest: BaseTest { + /** + * @brief Bind an ephemeral port using an externally driven event loop. + */ + void SetUp() override { + BaseTest::SetUp(); + http::configure_gamestream_server(server); + server.io_service = std::make_shared(); + server.config.address = "127.0.0.1"; + server.config.port = 0; + server.config.timeout_request = 1; + server.config.timeout_content = 1; + server.resource["^/serverinfo$"]["GET"] = [](auto response, auto) { + response->write("ok"); + }; + server.start([this](unsigned short assigned_port) { + port = assigned_port; + }); + server.io_service->poll(); + ASSERT_NE(port, 0); + } + + /** + * @brief Drain canceled callbacks before destroying the server. + */ + void TearDown() override { + server.stop(); + server.io_service->restart(); + server.io_service->poll(); + BaseTest::TearDown(); + } + + /** + * @brief Send a request, optionally splitting its body across asynchronous reads. + * @param request Initial request bytes. + * @param fragment Additional bytes sent after the first read has run. + * @return Bytes the server returned before closing or waiting for more input. + */ + std::string exchange(const std::string &request, const std::string &fragment = {}) { + boost::asio::io_context client_io; + tcp::socket client {client_io}; + client.connect({boost::asio::ip::address_v4::loopback(), port}); + boost::asio::write(client, boost::asio::buffer(request)); + if (!fragment.empty()) { + server.io_service->restart(); + server.io_service->run_for(30ms); + boost::asio::write(client, boost::asio::buffer(fragment)); + } + client.shutdown(tcp::socket::shutdown_send); + server.io_service->restart(); + server.io_service->run_for(100ms); + client.non_blocking(true); + std::string response; + std::array buffer {}; + boost::system::error_code ec; + for (;;) { + const auto count = client.read_some(boost::asio::buffer(buffer), ec); + response.append(buffer.data(), count); + if (ec) { + break; + } + } + return response; + } + + /** + * @brief Verify the listener still serves an ordinary request after malformed input. + */ + void expect_healthy() { + EXPECT_NE(exchange("GET /serverinfo HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").find("200 OK"), std::string::npos); + } + + SimpleWeb::Server server; ///< Actual dependency reader under test. + unsigned short port = 0; ///< Assigned loopback port. + }; + + TEST_F(HttpRequestLimitsTest, TruncatedChunkDoesNotStopTheServer) { + EXPECT_NO_THROW(exchange("GET /serverinfo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nA")); + expect_healthy(); + } + + TEST_F(HttpRequestLimitsTest, TruncatedChunkAfterSplitReadDoesNotStopTheServer) { + EXPECT_NO_THROW(exchange("GET /serverinfo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n1\r\n", "A")); + expect_healthy(); + } + + TEST_F(HttpRequestLimitsTest, BoundsHeadersWithoutRejectingPairingSizedQueries) { + EXPECT_NE(exchange("GET /serverinfo?clientcert=" + std::string(8192, 'a') + " HTTP/1.1\r\nConnection: close\r\n\r\n").find("200 OK"), std::string::npos); + EXPECT_EQ(exchange("GET /serverinfo HTTP/1.1\r\nX-Large: " + std::string(http::MAX_GAMESTREAM_REQUEST_SIZE, 'a') + "\r\n\r\n").find("200 OK"), std::string::npos); + expect_healthy(); + } + + TEST_F(HttpRequestLimitsTest, BoundsRequestBodies) { + const auto body = std::string(http::MAX_GAMESTREAM_REQUEST_SIZE + 1, 'a'); + EXPECT_EQ(exchange("GET /serverinfo HTTP/1.1\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body).find("200 OK"), std::string::npos); + expect_healthy(); + } +} // namespace