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 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-11-05 18:49:10 +01:00
1 parent 5b3ddcc062
commit bea3fcdeab
10 files changed
+170 -19

No files matched your search

+6
View File
@@ -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);
+27 -6
View File
@@ -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++;
+19 -2
View File
@@ -13,6 +13,7 @@
#else
#include <sys/select.h> /* FD_SETSIZE */
#include <sys/socket.h> /* socket definitions */
#include <sys/types.h> /* socket types */
#include <sys/wait.h> /* for waitpid() */
@@ -43,10 +44,14 @@
#include "Common/Log.h"
void NewThreadExecutor::Run(std::function<void()> func) {
bool NewThreadExecutor::Run(std::function<void()> 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<std::atomic<bool>>(false);
Worker worker;
@@ -56,6 +61,7 @@ void NewThreadExecutor::Run(std::function<void()> 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 {
+4 -1
View File
@@ -13,7 +13,10 @@
class NewThreadExecutor {
public:
~NewThreadExecutor();
void Run(std::function<void()> func);
// Returns false, without running func, when MAX_WORKERS threads are already busy.
bool Run(std::function<void()> func);
static constexpr size_t MAX_WORKERS = 64;
private:
// Reap threads that have finished. Only called from the thread that calls Run().
+2 -1
View File
@@ -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<std::string_view, std::string_view> BufferParts() const;
void Fill();
bool Block();
void AccountDrain(size_t bytes);
size_t FindNewline() const;
+31 -2
View File
@@ -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);
+4
View File
@@ -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;
+56
View File
@@ -16,11 +16,13 @@
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <cctype>
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <vector>
#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;
+9 -5
View File
@@ -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);
+12 -2
View File
@@ -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).