From bea3fcdeab04427e04d3a36aa0ef9e16bece8afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 5 Nov 2026 18:12:10 +0100 Subject: [PATCH] Web server, debugger: Check Origin, bound connections, headers and buffers Browsers let any page open a WebSocket to localhost, and the debugger can write memory and save states to any path. Accept only pages from this machine, from this server, or the official web debugger; clients that send no Origin are unaffected. - Cap request headers at 100 lines, 64 KB and 10 seconds, and stop leaking repeated User-Agent and Referer values. Header names are now matched exactly, not by prefix. - Run at most 64 connections at once, and drop accepted sockets that select() can't take (fd >= FD_SETSIZE). - Grow a WebSocket message as its data arrives, instead of to the length the frame claims. - Drop a WebSocket client that stops reading, once 128 MB is queued, and stop waiting for a close frame that can't go out after 2 seconds. - Uploads wait for data instead of spinning, and range downloads stop when the client stops reading. Co-Authored-By: Claude Opus 5.5 --- Common/File/FileDescriptor.cpp | 6 ++++ Common/Net/HTTPHeaders.cpp | 33 ++++++++++++++++---- Common/Net/HTTPServer.cpp | 21 +++++++++++-- Common/Net/HTTPServer.h | 5 ++- Common/Net/Sinks.h | 3 +- Common/Net/WebsocketServer.cpp | 33 ++++++++++++++++++-- Common/Net/WebsocketServer.h | 4 +++ Core/Debugger/WebSocket.cpp | 56 ++++++++++++++++++++++++++++++++++ Core/WebServer.cpp | 14 ++++++--- docs/WebSocketDebugger.md | 14 +++++++-- 10 files changed, 170 insertions(+), 19 deletions(-) diff --git a/Common/File/FileDescriptor.cpp b/Common/File/FileDescriptor.cpp index 61fa4a73e8..8a365a4ec0 100644 --- a/Common/File/FileDescriptor.cpp +++ b/Common/File/FileDescriptor.cpp @@ -13,6 +13,12 @@ namespace fd_util { bool WaitUntilReady(int fd, double timeout, bool for_write) { +#if !PPSSPP_PLATFORM(WINDOWS) + // FD_SET on an fd past the end of the set writes off the end of it, on the stack. + if (fd < 0 || fd >= FD_SETSIZE) { + return false; + } +#endif struct timeval tv; tv.tv_sec = (long)floor(timeout); tv.tv_usec = (long)((timeout - floor(timeout)) * 1000000.0); diff --git a/Common/Net/HTTPHeaders.cpp b/Common/Net/HTTPHeaders.cpp index bbd3a3fcfd..4fccbe4c68 100644 --- a/Common/Net/HTTPHeaders.cpp +++ b/Common/Net/HTTPHeaders.cpp @@ -9,9 +9,19 @@ #include "Common/Log.h" #include "Common/File/FileDescriptor.h" #include "Common/StringUtils.h" +#include "Common/TimeUtil.h" namespace http { +// Limits on a request's header block, so a client can't hold a connection or grow memory forever. +static constexpr int MAX_HEADER_LINES = 100; +static constexpr size_t MAX_HEADER_BYTES = 64 * 1024; +static constexpr double MAX_HEADER_SECONDS = 10.0; + +static bool KeyIs(const char *key, int key_len, const char *name) { + return key_len == (int)strlen(name) && !strncasecmp(key, name, key_len); +} + RequestHeader::RequestHeader() { } @@ -58,13 +68,13 @@ int RequestHeader::ParseHttpHeader(const char *buffer) { if (first_header_) { // Step 1: Method first_header_ = false; - if (!memcmp(buffer, "GET ", 4)) { + if (!strncmp(buffer, "GET ", 4)) { method = GET; buffer += 4; - } else if (!memcmp(buffer, "HEAD ", 5)) { + } else if (!strncmp(buffer, "HEAD ", 5)) { method = HEAD; buffer += 5; - } else if (!memcmp(buffer, "POST ", 5)) { + } else if (!strncmp(buffer, "POST ", 5)) { method = POST; buffer += 5; } else { @@ -133,14 +143,16 @@ int RequestHeader::ParseHttpHeader(const char *buffer) { SkipSpace(&buffer); int value_len = (int)strlen(buffer); - if (!strncasecmp(key, "User-Agent", key_len)) { + if (KeyIs(key, key_len, "User-Agent")) { + delete[] user_agent; user_agent = new char[value_len + 1]; memcpy(user_agent, buffer, value_len + 1); VERBOSE_LOG(Log::HTTP, "user-agent: %s", user_agent); - } else if (!strncasecmp(key, "Referer", key_len)) { + } else if (KeyIs(key, key_len, "Referer")) { + delete[] referer; referer = new char[value_len + 1]; memcpy(referer, buffer, value_len + 1); - } else if (!strncasecmp(key, "Content-Length", key_len)) { + } else if (KeyIs(key, key_len, "Content-Length")) { content_length = atoi(buffer); VERBOSE_LOG(Log::HTTP, "Content-Length: %i", (int)content_length); } else { @@ -154,12 +166,21 @@ int RequestHeader::ParseHttpHeader(const char *buffer) { void RequestHeader::ParseHeaders(net::InputSink *sink) { int line_count = 0; + size_t total_bytes = 0; + const double deadline = time_now_d() + MAX_HEADER_SECONDS; std::string line; while (sink->ReadLine(line)) { if (line.length() == 0) { // Blank line, this means end of headers. break; } + total_bytes += line.length(); + if (line_count >= MAX_HEADER_LINES || total_bytes > MAX_HEADER_BYTES || time_now_d() > deadline) { + WARN_LOG(Log::HTTP, "Request headers too long, dropping the request"); + status = 431; + ok = false; + return; + } ParseHttpHeader(line.c_str()); line_count++; diff --git a/Common/Net/HTTPServer.cpp b/Common/Net/HTTPServer.cpp index 04db0acf9c..ecc03c07cb 100644 --- a/Common/Net/HTTPServer.cpp +++ b/Common/Net/HTTPServer.cpp @@ -13,6 +13,7 @@ #else +#include /* FD_SETSIZE */ #include /* socket definitions */ #include /* socket types */ #include /* for waitpid() */ @@ -43,10 +44,14 @@ #include "Common/Log.h" -void NewThreadExecutor::Run(std::function func) { +bool NewThreadExecutor::Run(std::function func) { // Every connection gets a thread, and we only ever joined them at shutdown - so a server that // had served N connections was still holding N joinable std::threads. Reap the finished ones. Prune(); + if (workers_.size() >= MAX_WORKERS) { + // Otherwise anyone who can connect gets a thread per connection, until creating one throws. + return false; + } auto done = std::make_shared>(false); Worker worker; @@ -56,6 +61,7 @@ void NewThreadExecutor::Run(std::function func) { done->store(true, std::memory_order_release); }); workers_.push_back(std::move(worker)); + return true; } void NewThreadExecutor::Prune() { @@ -328,7 +334,18 @@ bool Server::RunSlice(double timeout) { socklen_t client_addr_size = sizeof(client_addr); int conn_fd = accept(listenerSock_, &client_addr.sa, &client_addr_size); if (conn_fd >= 0) { - executor_->Run(std::bind(&Server::HandleConnection, this, conn_fd)); +#if !PPSSPP_PLATFORM(WINDOWS) + // The sinks and the WebSocket server wait on the socket with select(). + if (conn_fd >= FD_SETSIZE) { + WARN_LOG(Log::HTTP, "Too many open files, dropping connection"); + closesocket(conn_fd); + return true; + } +#endif + if (!executor_->Run(std::bind(&Server::HandleConnection, this, conn_fd))) { + WARN_LOG(Log::HTTP, "Too many connections, dropping one"); + closesocket(conn_fd); + } return true; } else { diff --git a/Common/Net/HTTPServer.h b/Common/Net/HTTPServer.h index b6d1be7fc9..f235c6fc54 100644 --- a/Common/Net/HTTPServer.h +++ b/Common/Net/HTTPServer.h @@ -13,7 +13,10 @@ class NewThreadExecutor { public: ~NewThreadExecutor(); - void Run(std::function func); + // Returns false, without running func, when MAX_WORKERS threads are already busy. + bool Run(std::function func); + + static constexpr size_t MAX_WORKERS = 64; private: // Reap threads that have finished. Only called from the thread that calls Run(). diff --git a/Common/Net/Sinks.h b/Common/Net/Sinks.h index 4fd621b03e..f5725a4824 100644 --- a/Common/Net/Sinks.h +++ b/Common/Net/Sinks.h @@ -27,6 +27,8 @@ public: bool Empty() const; bool TryFill(); + // Waits (up to a few seconds) for more data to arrive. False on timeout, error or end of stream. + bool Block(); bool HasError() const { return hasError_; } // True once the peer has closed its end. Sticky - no more data can ever arrive, which is not // the same as "nothing right now", and callers that wait for more need to tell them apart. @@ -44,7 +46,6 @@ public: private: std::pair BufferParts() const; void Fill(); - bool Block(); void AccountDrain(size_t bytes); size_t FindNewline() const; diff --git a/Common/Net/WebsocketServer.cpp b/Common/Net/WebsocketServer.cpp index 0a9323feb0..bf667ec566 100644 --- a/Common/Net/WebsocketServer.cpp +++ b/Common/Net/WebsocketServer.cpp @@ -12,6 +12,7 @@ #include "Common/Crypto/sha1.h" #include "Common/Log.h" #include "Common/StringUtils.h" +#include "Common/TimeUtil.h" static const char *const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; @@ -19,6 +20,10 @@ static const char *const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" // claiming an enormous frame length before sending any actual data. // TODO: Make configurable? static constexpr uint64_t MAX_WS_MESSAGE_SIZE = 128 * 1024 * 1024; +// How much we queue for a client that isn't reading, before giving up on it. +static constexpr size_t MAX_WS_OUT_BACKLOG = 128 * 1024 * 1024; +// How long we wait for our close frame to go out. +static constexpr double WS_CLOSE_TIMEOUT_SECONDS = 2.0; namespace net { @@ -214,6 +219,7 @@ void WebSocketServer::Close(WebSocketClose reason) { } sentClose_ = true; + sentCloseTime_ = time_now_d(); } // The connection is gone or unusable - there's nothing left to send and no point waiting for @@ -247,6 +253,11 @@ bool WebSocketServer::Process(float timeout) { open_ = false; return false; } + if (sentClose_ && time_now_d() - sentCloseTime_ > WS_CLOSE_TIMEOUT_SECONDS) { + // The peer isn't reading, so the close frame is never going out. + CloseAbnormally(); + return false; + } struct timeval tv; tv.tv_sec = floor(timeout); @@ -437,11 +448,14 @@ bool WebSocketServer::ReadFrame() { } bool WebSocketServer::ReadPending() { + // Grow by at most what one read can return, not by the length the frame claims: a client could + // otherwise make us commit (and zero, on every lap) MAX_WS_MESSAGE_SIZE without sending it. size_t pos = pendingBuf_.size(); - pendingBuf_.resize(pendingBuf_.size() + pendingLeft_); + size_t chunk = (size_t)std::min(pendingLeft_, (uint64_t)InputSink::BUFFER_SIZE); + pendingBuf_.resize(pos + chunk); // Read what we can. - size_t readBytes = in_->TakeAtMost((char *)&pendingBuf_[pos], pendingLeft_); + size_t readBytes = in_->TakeAtMost((char *)&pendingBuf_[pos], chunk); for (size_t i = 0; i < readBytes; ++i) { pendingBuf_[pos + i] ^= pendingMask_[i & 3]; } @@ -599,6 +613,9 @@ void WebSocketServer::SendHeader(bool fin, int opcode, size_t sz) { } void WebSocketServer::SendBytes(const void *p, size_t sz) { + if (outputOverflowed_) { + return; + } const char *data = (const char *)p; if (OutBufPending() == 0) { size_t pushed = out_->PushAtMost(data, sz); @@ -607,6 +624,18 @@ void WebSocketServer::SendBytes(const void *p, size_t sz) { } if (sz != 0) { + if (OutBufPending() + sz > MAX_WS_OUT_BACKLOG) { + // The client stopped reading. Drop the connection rather than buffer without limit. + ERROR_LOG(Log::IO, "WebSocket client isn't reading, dropping it"); + outputOverflowed_ = true; + closeReason_ = WebSocketClose::ABNORMAL; + sentClose_ = true; + sentCloseTime_ = time_now_d(); + outBuf_.clear(); + outBufOffset_ = 0; + out_->Discard(); + return; + } size_t pos = outBuf_.size(); outBuf_.resize(pos + sz); memcpy(&outBuf_[pos], data, sz); diff --git a/Common/Net/WebsocketServer.h b/Common/Net/WebsocketServer.h index 398fa32dff..a35f261aff 100644 --- a/Common/Net/WebsocketServer.h +++ b/Common/Net/WebsocketServer.h @@ -90,6 +90,10 @@ protected: bool open_ = true; bool sentClose_ = false; + // When sentClose_ was set. A peer that never reads would otherwise keep us waiting forever. + double sentCloseTime_ = 0.0; + // Set when the peer stopped reading and the backlog hit its cap. Nothing more gets queued. + bool outputOverflowed_ = false; int fragmentOpcode_ = -1; size_t fd_ = 0; InputSink *in_ = nullptr; diff --git a/Core/Debugger/WebSocket.cpp b/Core/Debugger/WebSocket.cpp index adfc515c23..0d0a725a02 100644 --- a/Core/Debugger/WebSocket.cpp +++ b/Core/Debugger/WebSocket.cpp @@ -16,11 +16,13 @@ // https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. #include +#include #include #include #include #include +#include "Common/StringUtils.h" #include "Common/Thread/ThreadUtil.h" #include "Common/TimeUtil.h" #include "Core/Core.h" @@ -226,9 +228,63 @@ void WebSocketDebuggerTick() { } } +// Browsers let any page open a WebSocket to localhost, and the debugger can write files and memory. +// So only accept pages from this machine, from this server (the bundled UI), or the official web +// debugger. Non-browser clients don't send an Origin. +static bool IsAllowedOrigin(const http::ServerRequest &request) { + std::string origin; + if (!request.GetHeader("origin", &origin)) { + return true; + } + auto toLower = [](std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)tolower(c); }); + return s; + }; + origin = toLower(origin); + + size_t schemeEnd = origin.find("://"); + if (schemeEnd == std::string::npos) { + // Includes "null", sent by file:// pages and sandboxed frames. + return false; + } + const std::string scheme = origin.substr(0, schemeEnd); + if (scheme != "http" && scheme != "https") { + return false; + } + const std::string hostPort = origin.substr(schemeEnd + 3); + if (hostPort == "ppsspp-debugger.unknownbrackets.org") { + return true; + } + + std::string hostname = hostPort; + if (startsWith(hostname, "[")) { + hostname = hostname.substr(0, hostname.find(']') + 1); + } else { + hostname = hostname.substr(0, hostname.find(':')); + } + if (hostname == "localhost" || hostname == "127.0.0.1" || hostname == "[::1]") { + return true; + } + + // A name matching Host proves nothing: an attacker's domain can be made to resolve to us (DNS + // rebinding). So only an IP address, the way the bundled UI is reached over the LAN. + const bool isIP = startsWith(hostname, "[") || (!hostname.empty() && hostname.find_first_not_of("0123456789.") == std::string::npos); + std::string host; + return isIP && request.GetHeader("host", &host) && toLower(host) == hostPort; +} + void HandleDebuggerRequest(const http::ServerRequest &request) { SetCurrentThreadName("WebSocketDebugger"); + if (!IsAllowedOrigin(request)) { + std::string origin; + request.GetHeader("origin", &origin); + WARN_LOG(Log::Debugger, "Rejected debugger connection from origin '%s'", origin.c_str()); + request.WriteHttpResponseHeader("1.1", 403, -1, "text/plain"); + request.Out()->Push("Origin not allowed."); + return; + } + net::WebSocketServer *ws = net::WebSocketServer::CreateAsUpgrade(request, "debugger.ppsspp.org"); if (!ws) { return; diff --git a/Core/WebServer.cpp b/Core/WebServer.cpp index 5eb4c2ffb3..431a47f4cd 100644 --- a/Core/WebServer.cpp +++ b/Core/WebServer.cpp @@ -310,7 +310,10 @@ static void DiscHandler(const http::ServerRequest &request, const Path &filename s64 chunklen = std::min(len - pos, (s64)CHUNK_SIZE); if (fread(buf, chunklen, 1, fp) != 1) break; - request.Out()->Push(buf, chunklen); + if (!request.Out()->Push(buf, chunklen)) { + // The client stopped reading or went away. Each failed Push already waited a while. + break; + } } fclose(fp); delete[] buf; @@ -679,10 +682,11 @@ static MultiPartResult HandleMultipartPart(const http::ServerRequest &request, s } progress.AddBytes(readBytes); bytesTransferred += readBytes; - if (readBytes == 0 && !terminatorFound && (request.In()->AtEnd() || request.In()->HasError())) { - // Peer went away mid-upload (a cancelled browser upload, say). Without this the loop - // spins forever, which also blocks web server shutdown since it joins its threads. - ERROR_LOG(Log::HTTP, "Connection closed during upload of '%s'", filename.c_str()); + // ReadBinaryUntilTerminator doesn't wait, so wait here for more data when there was none. + if (readBytes == 0 && !terminatorFound && !request.In()->Block()) { + // Peer went away mid-upload (a cancelled browser upload, say), or stopped sending. Without + // this the loop spins forever, which also blocks web server shutdown since it joins its threads. + ERROR_LOG(Log::HTTP, "Connection closed or timed out during upload of '%s'", filename.c_str()); if (fp) { fclose(fp); File::Delete(destPath); diff --git a/docs/WebSocketDebugger.md b/docs/WebSocketDebugger.md index d4409e2b9e..0055c34733 100644 --- a/docs/WebSocketDebugger.md +++ b/docs/WebSocketDebugger.md @@ -34,8 +34,18 @@ This doc is a local reference, the user-facing documentation is on the website. to `/debugger` without a websocket Upgrade just redirects to the bundled web UI at `/debugger/index.html`). - Messages are JSON, both directions, always shaped as `{"event": "NAME", ...}`. -- One WebSocket connection = one client; PPSSPP does not limit the number of - simultaneous debugger connections. +- One WebSocket connection = one client. The web server as a whole handles at + most 64 connections at a time (`NewThreadExecutor::MAX_WORKERS`), debugger + connections included. +- Browser pages are checked by their `Origin` header, since any page can open a + WebSocket to localhost and the debugger can write memory and files. Allowed: + no `Origin` at all (native clients, wsdbg, scripts), pages served from + `localhost`, `127.0.0.1` or `[::1]` on any port, pages served by this server + itself by IP address (the `Origin` matches `Host`, as for the bundled UI; a + hostname doesn't count, since DNS rebinding can point one at us), and + `ppsspp-debugger.unknownbrackets.org`. Anything else gets a 403. +- A client that stops reading gets dropped once 128 MB of responses are queued + for it. - The debugger only actually does anything while `WebServerFlags::DEBUGGER` is enabled (see "Enabling it" below) - the HTTP server itself may also be running for other reasons (Remote ISO, upload).