mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-11-13 09:39:33 +01:00
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:
1 parent
5b3ddcc062
commit
bea3fcdeab
10 files changed
+170
-19
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user