summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorErik Kundiman <erik@megapahit.org>2026-10-03 12:53:04 +0800
committerErik Kundiman <erik@megapahit.org>2026-10-03 12:53:04 +0800
commit695133b0e9eae18ca99aee8d8606c297a0394475 (patch)
tree8bd983ec3941c9f18271528f4eeb616a2987871e
parentd45e5f46335f8ed579f7663cdd7021c876e52652 (diff)
parent0ebee4d8fa2997744dcd882b4be604aa6c14c312 (diff)
Merge tag 'Second_Life_Release#0ebee4d8-26.4' into 26.4
-rw-r--r--doc/external-editor-json-rpc.md15
-rw-r--r--indra/llcorehttp/CMakeLists.txt5
-rw-r--r--indra/llcorehttp/lljsonrpcws.cpp29
-rw-r--r--indra/llcorehttp/lljsonrpcws.h21
-rw-r--r--indra/llcorehttp/llwebsocketmgr.cpp29
-rw-r--r--indra/llcorehttp/llwebsocketmgr.h15
-rw-r--r--indra/llcorehttp/tests/jsonrpcws_test.cpp553
-rw-r--r--indra/llcorehttp/tests/llcorehttp_test.cpp1
-rw-r--r--indra/llcorehttp/tests/test_jsonrpcws.hpp57
-rw-r--r--indra/llcorehttp/tests/test_websocketmgr.hpp157
-rw-r--r--indra/llcorehttp/tests/testwsclient.h170
-rw-r--r--indra/llcorehttp/tests/wsserver_test.cpp438
-rw-r--r--indra/newview/llscripteditorws.cpp232
-rw-r--r--indra/newview/llscripteditorws.h35
-rw-r--r--indra/newview/tests/llscripteditorws_test.cpp130
15 files changed, 1815 insertions, 72 deletions
diff --git a/doc/external-editor-json-rpc.md b/doc/external-editor-json-rpc.md
index b62e002a33..80cc135522 100644
--- a/doc/external-editor-json-rpc.md
+++ b/doc/external-editor-json-rpc.md
@@ -65,9 +65,14 @@ This document describes all the message interfaces defined for WebSocket communi
1. **Connection Establishment:**
+ - The viewer refuses a WebSocket upgrade request that carries an `Origin` header (HTTP 403):
+ browsers send one with every socket a page opens, and a program connecting need not
- Viewer sends `session.handshake` call with `SessionHandshake` data
- - Extension responds with `SessionHandshakeResponse`
+ - Extension responds with `SessionHandshakeResponse`, answering the challenge
- Viewer confirms with `session.ok` notification
+ - Until `session.ok`, every request the extension makes is refused with `-32002`
+ (Authentication required) and the viewer sends it no notifications. A wrong challenge
+ response, an error in place of one, or no response within 30 seconds closes the connection.
2. **Language Information Exchange:**
@@ -291,7 +296,7 @@ interface SessionHandshake {
viewer_version: string;
agent_id: string;
agent_name: string;
- challenge?: string;
+ challenge: string;
languages: string[];
syntax_id: string;
features: { [feature: string]: boolean };
@@ -306,7 +311,7 @@ interface SessionHandshake {
- `viewer_version`: Version string of the viewer
- `agent_id`: Unique identifier for the user/agent
- `agent_name`: Human-readable name of the agent
-- `challenge` (optional): Path to a temporary file on the local filesystem containing a UUID. The client must read this file and return the UUID as `challenge_response` to authenticate the connection.
+- `challenge`: Path to a temporary file on the local filesystem containing a UUID, and nothing else. The client must read this file and return its contents as `challenge_response` to authenticate the connection. The file is named at random, readable only by the user running the viewer, and deleted once the handshake is answered or abandoned, so only a client running as that user can answer.
- `languages`: Array of supported scripting languages (e.g., `["lsl", "luau"]`)
- `syntax_id`: Current active syntax identifier as a UUID string
- `features`: Dictionary of feature flags indicating viewer capabilities. Known flags:
@@ -327,7 +332,7 @@ interface SessionHandshakeResponse {
client_name: string;
client_version: "1.0";
protocol_version: string;
- challenge_response?: string;
+ challenge_response: string;
languages: string[];
features: { [feature: string]: boolean };
script_name?: string;
@@ -340,7 +345,7 @@ interface SessionHandshakeResponse {
- `client_name`: Name of the client (VS Code extension)
- `client_version`: Fixed version "1.0" of the client
- `protocol_version`: Protocol version the client supports
-- `challenge_response` (optional): The UUID read from the temporary file identified by the `challenge` field in the handshake. Must be provided if `challenge` was present, otherwise the connection will be closed.
+- `challenge_response`: The UUID read from the temporary file identified by the `challenge` field in the handshake. Without it, or with any other value, the connection is closed.
- `languages`: Array of languages supported by the client
- `features`: Dictionary of features supported by the client. Known flags:
- `live_sync`: Client supports live script synchronisation
diff --git a/indra/llcorehttp/CMakeLists.txt b/indra/llcorehttp/CMakeLists.txt
index 2cf889bb9d..058c3feac5 100644
--- a/indra/llcorehttp/CMakeLists.txt
+++ b/indra/llcorehttp/CMakeLists.txt
@@ -123,6 +123,7 @@ if (LL_TESTS AND LLCOREHTTP_TESTS)
tests/test_bufferarray.hpp
tests/test_bufferstream.hpp
tests/test_jsonrpcws.hpp
+ tests/test_websocketmgr.hpp
)
list(APPEND llcorehttp_TEST_SOURCE_FILES ${llcorehttp_TEST_HEADER_FILES})
@@ -147,6 +148,10 @@ if (LL_TESTS AND LLCOREHTTP_TESTS)
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_llcorehttp_peer.py"
)
+ LL_ADD_INTEGRATION_TEST(wsserver "" "${test_libs}")
+
+ LL_ADD_INTEGRATION_TEST(jsonrpcws "" "${test_libs}")
+
#
# Example Programs
#
diff --git a/indra/llcorehttp/lljsonrpcws.cpp b/indra/llcorehttp/lljsonrpcws.cpp
index 4745be64b3..f04738d805 100644
--- a/indra/llcorehttp/lljsonrpcws.cpp
+++ b/indra/llcorehttp/lljsonrpcws.cpp
@@ -114,7 +114,7 @@ void LLJSONRPCConnection::onMessage(const std::string& message)
catch (const std::exception& e)
{
LL_WARNS("JSONRPC") << "Exception processing JSON-RPC message: " << e.what() << LL_ENDL;
- sendError(LLSD(), InternalError(e.what()));
+ sendError(LLSD(), InternalError());
}
}
@@ -163,6 +163,18 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
LL_DEBUGS("JSONRPC") << "Processing " << (is_notification ? "notification" : "request")
<< " for method: " << method << LL_ENDL;
+ // Nothing for a peer that has not proven itself, and nothing said of
+ // which methods there are.
+ if (!mAuthenticated)
+ {
+ LL_DEBUGS("JSONRPC") << "Refused " << method << " from a peer not authenticated" << LL_ENDL;
+ if (!is_notification)
+ {
+ sendError(id, UnauthorizedError());
+ }
+ return;
+ }
+
// Resolve the handler under the mutex, then invoke it unlocked.
MethodHandler handler;
bool is_async = false;
@@ -237,7 +249,9 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
{
if (conn->isConnected())
{
- conn->sendError(id, InternalError(e.what()));
+ LL_WARNS("JSONRPC") << "Exception in async method " << method
+ << ": " << e.what() << LL_ENDL;
+ conn->sendError(id, InternalError());
}
else
{
@@ -289,7 +303,9 @@ void LLJSONRPCConnection::processRequest(const LLSD& request)
{
if (!is_notification)
{
- sendError(id, InternalError(e.what()));
+ LL_WARNS("JSONRPC") << "Exception in handler for " << method
+ << ": " << e.what() << LL_ENDL;
+ sendError(id, InternalError());
}
else
{
@@ -433,8 +449,7 @@ void LLJSONRPCConnection::sweepTimeouts()
for (auto& [id, callback] : expired)
{
- LL_WARNS("JSONRPC") << "Request " << id << " timed out after "
- << REQUEST_TIMEOUT_SECONDS << " seconds" << LL_ENDL;
+ LL_WARNS("JSONRPC") << "Request " << id << " timed out" << LL_ENDL;
if (callback)
{
LLSD error;
@@ -553,7 +568,7 @@ LLSD LLJSONRPCConnection::makeEnvelope(const LLSD& id,
return env;
}
-LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, ResponseCallback callback)
+LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, ResponseCallback callback, F64 timeout)
{
LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
LLSD id = generateId();
@@ -566,7 +581,7 @@ LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, Re
{
LLMutexLock lock(&mMutex);
mPendingRequests[id_str] = std::move(callback);
- mPendingDeadlines.push({ LLTimer::getTotalSeconds() + REQUEST_TIMEOUT_SECONDS, id_str });
+ mPendingDeadlines.push({ LLTimer::getTotalSeconds() + timeout, id_str });
}
// Send the request
diff --git a/indra/llcorehttp/lljsonrpcws.h b/indra/llcorehttp/lljsonrpcws.h
index 08a6cefd22..98c858b022 100644
--- a/indra/llcorehttp/lljsonrpcws.h
+++ b/indra/llcorehttp/lljsonrpcws.h
@@ -284,10 +284,23 @@ public:
* @param method The method name to call
* @param params The parameters to pass
* @param callback Callback for the response (optional)
- * @return The request ID for correlation
+ * @param timeout Seconds before the callback is told the call timed out
+ * @return The request ID for correlation, undefined where it could not
+ * be sent (the callback is then never called)
*/
LLSD call(const std::string& method, const LLSD& params = LLSD(),
- ResponseCallback callback = nullptr);
+ ResponseCallback callback = nullptr, F64 timeout = REQUEST_TIMEOUT_SECONDS);
+
+ /**
+ * @brief Whether the peer's requests are dispatched
+ *
+ * A connection starts out unauthenticated and refuses every request as
+ * unauthorized, whether or not the method exists, until it is told the
+ * peer has proven itself -- e.g. by answering a challenge. Answers to
+ * its own calls go through either way: that is how the peer proves
+ * itself.
+ */
+ bool isAuthenticated() const { return mAuthenticated.load(); }
/**
* @brief Send a JSON-RPC notification (no response expected)
@@ -360,6 +373,8 @@ protected:
*/
LLSD generateId();
+ void setAuthenticated(bool authenticated) { mAuthenticated = authenticated; }
+
public:
/**
* @brief Build a JSON-RPC 2.0 envelope.
@@ -400,6 +415,8 @@ private:
};
std::priority_queue<PendingDeadline> mPendingDeadlines;
+ std::atomic<bool> mAuthenticated{ false };
+
static constexpr F64 REQUEST_TIMEOUT_SECONDS = 120.0;
public:
diff --git a/indra/llcorehttp/llwebsocketmgr.cpp b/indra/llcorehttp/llwebsocketmgr.cpp
index 3c0e0ed952..ef07295eed 100644
--- a/indra/llcorehttp/llwebsocketmgr.cpp
+++ b/indra/llcorehttp/llwebsocketmgr.cpp
@@ -195,6 +195,7 @@ struct Server_impl
mLocalOnly(local_only)
{
+ mServer.set_validate_handler([this](websocketpp::connection_hdl hdl) { return this->onValidate(hdl); });
mServer.set_open_handler([this](websocketpp::connection_hdl hdl) { this->onOpen(hdl); });
mServer.set_close_handler([this](websocketpp::connection_hdl hdl) { this->onClose(hdl); });
mServer.set_message_handler([this](websocketpp::connection_hdl hdl, Server_t::message_ptr msg) { this->onMessage(hdl, msg); });
@@ -312,6 +313,34 @@ struct Server_impl
}
/**
+ * @brief Decide whether an upgrade request is let in
+ * @param hdl WebSocket connection handle from websocketpp
+ * @return false to answer the request 403 and never open it
+ *
+ * Called by websocketpp once the request has been read, before it is
+ * answered: the owner says which origins it takes.
+ */
+ bool onValidate(websocketpp::connection_hdl hdl)
+ {
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET;
+ websocketpp::lib::error_code ec;
+ Server_t::connection_ptr con = mServer.get_con_from_hdl(hdl, ec);
+ if (ec || !con)
+ {
+ return false;
+ }
+ const std::string& origin = con->get_origin();
+ if (mOwner->acceptOrigin(origin))
+ {
+ return true;
+ }
+ // Once for each origin: a page can ask as often as it likes.
+ LL_WARNS_ONCE("WebSocket") << mOwner->mServerName << " refused a connection from origin " << origin << LL_ENDL;
+ con->set_status(websocketpp::http::status_code::forbidden);
+ return false;
+ }
+
+ /**
* @brief Handle new connection establishment event
* @param hdl WebSocket connection handle from websocketpp
*
diff --git a/indra/llcorehttp/llwebsocketmgr.h b/indra/llcorehttp/llwebsocketmgr.h
index b962b4b117..19ceb5feed 100644
--- a/indra/llcorehttp/llwebsocketmgr.h
+++ b/indra/llcorehttp/llwebsocketmgr.h
@@ -122,7 +122,7 @@ public:
* Sends a text message to the remote endpoint. The message is queued
* asynchronously and may not be sent immediately.
*/
- bool sendMessage(const std::string& message) const;
+ virtual bool sendMessage(const std::string& message) const;
bool sendMessage(const boost::json::value& json) const;
bool sendMessage(const LLSD& data) const;
@@ -243,6 +243,19 @@ public:
void broadcastMessage(const std::string& message);
virtual bool update() { return true; }
+ /**
+ * @brief Whether an upgrade request from this origin is let in
+ * @param origin The request's Origin header, empty where it had none
+ *
+ * A browser sends the page's origin with every WebSocket it opens,
+ * and nothing stops a page -- or media on a prim, in the viewer's
+ * own browser -- from opening one to 127.0.0.1. A program, such as
+ * an editor's extension, sends none. By default only those are let
+ * in; a request refused is answered 403 and never opens. Called on
+ * the server's thread.
+ */
+ virtual bool acceptOrigin(const std::string& origin) const { return origin.empty(); }
+
connection_state_t getConnectionState(const connection_h& handle) const;
protected:
diff --git a/indra/llcorehttp/tests/jsonrpcws_test.cpp b/indra/llcorehttp/tests/jsonrpcws_test.cpp
new file mode 100644
index 0000000000..c07cb6f4fd
--- /dev/null
+++ b/indra/llcorehttp/tests/jsonrpcws_test.cpp
@@ -0,0 +1,553 @@
+/**
+ * @file jsonrpcws_test.cpp
+ * @brief Security-focused unit tests for LLJSONRPCConnection / LLJSONRPCServer
+ *
+ * $LicenseInfo:firstyear=2026&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2026, Linden Research, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ *
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
+ * $/LicenseInfo$
+ */
+
+#include "linden_common.h"
+
+#include "lljsonrpcws.h"
+#include "llsdjson.h"
+
+#include "../test/lltut.h"
+
+#include <boost/json.hpp>
+#include <vector>
+
+namespace
+{
+ // Exposes the protected test-only members LLJSONRPCConnection provides
+ // for exactly this purpose, on a connection with no real transport.
+ class TestJSONRPCConnection : public LLJSONRPCConnection
+ {
+ public:
+ TestJSONRPCConnection()
+ : LLJSONRPCConnection(LLWebsocketMgr::WSServer::ptr_t(), LLWebsocketMgr::connection_h())
+ {
+ }
+
+ using LLJSONRPCConnection::processMessage;
+ using LLJSONRPCConnection::validateMessage;
+ using LLJSONRPCConnection::testInjectPendingRequest;
+ using LLJSONRPCConnection::testPendingRequestCount;
+ using LLJSONRPCConnection::testSweepTimeouts;
+ using LLJSONRPCConnection::setAuthenticated;
+ };
+
+ // Minimal concrete server, used only to register the built-in
+ // system.* methods on a connection via setupConnectionMethods() --
+ // LLJSONRPCServer itself is abstract (handleGetVersion is pure virtual).
+ // Never started, so port 0 is fine.
+ class TestJSONRPCServer : public LLJSONRPCServer
+ {
+ public:
+ TestJSONRPCServer() : LLJSONRPCServer("test_jsonrpcws_server", 0, true) {}
+
+ using LLJSONRPCServer::setupConnectionMethods;
+
+ LLSD handleGetVersion(const LLJSONRPCConnection::ptr_t&, const LLSD&) const override
+ {
+ return LLSD();
+ }
+ };
+
+ // Captures what would be sent to the peer, since there is no real
+ // transport to observe it on.
+ class CapturingJSONRPCConnection : public TestJSONRPCConnection
+ {
+ public:
+ mutable std::vector<std::string> mSent;
+
+ bool sendMessage(const std::string& message) const override
+ {
+ mSent.push_back(message);
+ return true;
+ }
+ };
+}
+
+namespace tut
+{
+ struct jsonrpcws_data
+ {
+ };
+
+ typedef test_group<jsonrpcws_data> jsonrpcws_test;
+ typedef jsonrpcws_test::object jsonrpcws_object;
+ jsonrpcws_test jsonrpcws_testcase("LLJSONRPCConnection");
+
+ template<> template<>
+ void jsonrpcws_object::test<1>()
+ {
+ set_test_name("an unauthenticated connection dispatches neither requests nor notifications");
+
+ TestJSONRPCConnection conn;
+ conn.setAuthenticated(false);
+
+ int calls = 0;
+ conn.registerMethod("object.list",
+ [&](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ ++calls;
+ return LLSD();
+ });
+
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(request);
+
+ const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(notification);
+
+ ensure_equals("neither a request nor a notification reaches the handler", calls, 0);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<2>()
+ {
+ set_test_name("once authenticated, requests and notifications dispatch normally");
+
+ TestJSONRPCConnection conn;
+ conn.setAuthenticated(true);
+ ensure("authenticated after the peer proves itself", conn.isAuthenticated());
+
+ int calls = 0;
+ conn.registerMethod("object.list",
+ [&](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ ++calls;
+ return LLSD();
+ });
+
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(request);
+ ensure_equals("the request reaches the handler", calls, 1);
+
+ const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(notification);
+ ensure_equals("the notification also reaches the handler", calls, 2);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<3>()
+ {
+ set_test_name("an unauthenticated connection still receives the answers to its own calls");
+
+ TestJSONRPCConnection conn;
+ ensure("unauthenticated by default", !conn.isAuthenticated());
+
+ LLSD answer;
+ conn.testInjectPendingRequest(
+ "rpc_1",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD& result, const LLSD& error)
+ {
+ ensure("no error", error.isUndefined());
+ answer = result;
+ });
+
+ LLSD result;
+ result["challenge_response"] = "secret";
+ conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("rpc_1"), std::string(), LLSD(), result, LLSD()));
+ ensure_equals("the answer reached the caller", answer["challenge_response"].asString(), std::string("secret"));
+ ensure("and it is answered", conn.testPendingRequestCount() == 0);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<4>()
+ {
+ set_test_name("an unauthenticated request is answered -32002; a notification gets nothing back");
+
+ CapturingJSONRPCConnection conn;
+ ensure("unauthenticated by default", !conn.isAuthenticated());
+
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(request);
+ ensure_equals("exactly one message sent for the request", conn.mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("unauthorized error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED);
+
+ const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(notification);
+ ensure_equals("nothing sent for a notification", conn.mSent.size(), (size_t)1);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<5>()
+ {
+ set_test_name("malformed JSON is answered with a parse error, and the connection survives");
+
+ CapturingJSONRPCConnection conn;
+ conn.setAuthenticated(true);
+
+ conn.onMessage("{not valid json");
+ ensure_equals("a parse error is sent", conn.mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("parse error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::PARSE_ERROR);
+
+ // The connection itself is unaffected: valid follow-up traffic still works.
+ int calls = 0;
+ conn.registerMethod("object.list",
+ [&](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ ++calls;
+ return LLSD();
+ });
+ conn.onMessage(boost::json::serialize(LlsdToJson(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()))));
+ ensure_equals("a later valid request still dispatches", calls, 1);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<6>()
+ {
+ set_test_name("a batch request is rejected outright; batch support is intentionally disabled");
+
+ CapturingJSONRPCConnection conn;
+ conn.setAuthenticated(true);
+
+ const LLSD one = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ LLSD batch = LLSD::emptyArray();
+ batch.append(one);
+ batch.append(one);
+
+ conn.onMessage(boost::json::serialize(LlsdToJson(batch)));
+ ensure_equals("a single error is sent for the whole batch", conn.mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("invalid request error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::INVALID_REQUEST);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<7>()
+ {
+ set_test_name("a missing or wrong jsonrpc version is rejected by validateMessage");
+
+ TestJSONRPCConnection conn;
+
+ LLSD missing_version;
+ missing_version["method"] = "object.list";
+ ensure("missing version fails validation", !conn.validateMessage(missing_version, true));
+
+ LLSD wrong_version = missing_version;
+ wrong_version["jsonrpc"] = "1.0";
+ ensure("wrong version fails validation", !conn.validateMessage(wrong_version, true));
+
+ LLSD correct_version = missing_version;
+ correct_version["jsonrpc"] = "2.0";
+ ensure("correct version passes validation", conn.validateMessage(correct_version, true));
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<8>()
+ {
+ set_test_name("params of the wrong type are rejected; the handler is never invoked");
+
+ TestJSONRPCConnection conn;
+ conn.setAuthenticated(true);
+
+ LLSD bad_params;
+ bad_params["jsonrpc"] = "2.0";
+ bad_params["method"] = "object.list";
+ bad_params["params"] = "not-an-array-or-object";
+ ensure("string params fail validation", !conn.validateMessage(bad_params, true));
+
+ int calls = 0;
+ conn.registerMethod("object.list",
+ [&](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ ++calls;
+ return LLSD();
+ });
+
+ conn.processMessage(bad_params);
+ ensure_equals("the handler is never invoked", calls, 0);
+
+ LLSD good_params = bad_params;
+ good_params["params"] = LLSD::emptyMap();
+ conn.processMessage(good_params);
+ ensure_equals("valid params do dispatch", calls, 1);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<9>()
+ {
+ set_test_name("a response for an unknown id is silently ignored");
+
+ TestJSONRPCConnection conn;
+
+ bool callback_called = false;
+ conn.testInjectPendingRequest(
+ "req_1",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD&, const LLSD&)
+ {
+ callback_called = true;
+ });
+
+ LLSD result;
+ result["value"] = "ok";
+ conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("unknown_id"), std::string(), LLSD(), result, LLSD()));
+
+ ensure("no callback fired for an unrelated id", !callback_called);
+ ensure_equals("the real pending request is still tracked", conn.testPendingRequestCount(), (size_t)1);
+
+ conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), std::string(), LLSD(), result, LLSD()));
+ ensure("the callback fires for its own id", callback_called);
+ ensure_equals("and it is removed once answered", conn.testPendingRequestCount(), (size_t)0);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<10>()
+ {
+ set_test_name("a response with both result and error, or with neither, is rejected as invalid");
+
+ TestJSONRPCConnection conn;
+
+ LLSD both;
+ both["jsonrpc"] = "2.0";
+ both["id"] = "req_1";
+ both["result"] = "ok";
+ both["error"]["code"] = -1;
+ both["error"]["message"] = "bad";
+ ensure("result and error together fail validation", !conn.validateMessage(both, false));
+
+ LLSD neither;
+ neither["jsonrpc"] = "2.0";
+ neither["id"] = "req_1";
+ ensure("neither result nor error fails validation", !conn.validateMessage(neither, false));
+
+ LLSD result_only;
+ result_only["jsonrpc"] = "2.0";
+ result_only["id"] = "req_1";
+ result_only["result"] = "ok";
+ ensure("result alone passes validation", conn.validateMessage(result_only, false));
+
+ bool callback_called = false;
+ conn.testInjectPendingRequest(
+ "req_1",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD&, const LLSD&)
+ {
+ callback_called = true;
+ });
+
+ conn.processMessage(both);
+ ensure("an invalid response never reaches the callback", !callback_called);
+ ensure_equals("the pending request is still tracked", conn.testPendingRequestCount(), (size_t)1);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<11>()
+ {
+ set_test_name("an unauthenticated peer is told unauthorized, never that the method doesn't exist");
+
+ CapturingJSONRPCConnection conn;
+
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "no.such.method", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(request);
+ ensure_equals("one message sent", conn.mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("unauthorized, not method-not-found", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED);
+
+ conn.setAuthenticated(true);
+ conn.processMessage(request);
+ ensure_equals("a second message sent once authenticated", conn.mSent.size(), (size_t)2);
+
+ sent = LlsdFromJson(boost::json::parse(conn.mSent[1], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("now it is method-not-found", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::METHOD_NOT_FOUND);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<12>()
+ {
+ set_test_name("unregisterMethod removes both sync and async handlers");
+
+ TestJSONRPCConnection conn;
+
+ conn.registerMethod("sync.method",
+ [](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ return LLSD();
+ });
+ conn.registerAsyncMethod("async.method",
+ [](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ return LLSD();
+ });
+
+ ensure_equals("sync method registered", conn.getMethods().count("sync.method"), (size_t)1);
+ ensure_equals("async method registered", conn.getMethods().count("async.method"), (size_t)1);
+
+ conn.unregisterMethod("sync.method");
+ conn.unregisterMethod("async.method");
+
+ ensure_equals("sync method gone", conn.getMethods().count("sync.method"), (size_t)0);
+ ensure_equals("async method gone", conn.getMethods().count("async.method"), (size_t)0);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<13>()
+ {
+ set_test_name("a handler throwing a plain std::exception is converted to a generic internal error");
+
+ CapturingJSONRPCConnection conn;
+ conn.setAuthenticated(true);
+
+ conn.registerMethod("boom",
+ [](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ throw std::runtime_error("boom detail");
+ });
+
+ conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "boom", LLSD::emptyMap(), LLSD(), LLSD()));
+ ensure_equals("one message sent", conn.mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("internal error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::INTERNAL_ERROR);
+
+ // The exception's own message must never reach the client -- it can
+ // carry paths, connection strings, or other internal detail.
+ ensure("the exception's own message is not forwarded to the client",
+ sent["error"]["message"].asString().find("boom detail") == std::string::npos);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<14>()
+ {
+ set_test_name("call() with no way to send returns undefined and leaves nothing pending");
+
+ TestJSONRPCConnection conn;
+
+ bool callback_called = false;
+ const LLSD id = conn.call("object.list", LLSD::emptyMap(),
+ [&](const LLSD&, const LLSD&)
+ {
+ callback_called = true;
+ });
+
+ ensure("call() reports it could not send", id.isUndefined());
+ ensure_equals("nothing left pending", conn.testPendingRequestCount(), (size_t)0);
+ ensure("the callback is never invoked for an unsent call", !callback_called);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<15>()
+ {
+ set_test_name("the built-in system.* methods are gated by authentication like any other method");
+
+ TestJSONRPCServer server;
+ auto conn = std::make_shared<CapturingJSONRPCConnection>();
+ server.setupConnectionMethods(conn);
+
+ ensure_equals("system.listMethods is registered", conn->getMethods().count("system.listMethods"), (size_t)1);
+ ensure_equals("system.getStats is registered", conn->getMethods().count("system.getStats"), (size_t)1);
+
+ conn->processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "system.listMethods", LLSD::emptyMap(), LLSD(), LLSD()));
+ ensure_equals("one message sent", conn->mSent.size(), (size_t)1);
+
+ boost::system::error_code ec;
+ const LLSD sent = LlsdFromJson(boost::json::parse(conn->mSent[0], ec));
+ ensure("parsed without error", !ec);
+ ensure_equals("unauthenticated, even a built-in method is refused", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED);
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<16>()
+ {
+ set_test_name("makeEnvelope includes id and method by the documented rules");
+
+ // Notification: id undefined, method non-empty -> id omitted.
+ const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ ensure("notification omits id", !notification.has("id"));
+ ensure_equals("notification keeps method", notification["method"].asString(), std::string("object.list"));
+
+ // Request: id defined, method non-empty -> both kept.
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ ensure("request keeps id", request.has("id"));
+ ensure_equals("request keeps method", request["method"].asString(), std::string("object.list"));
+
+ // Success response: id defined, method empty -> id kept, method omitted.
+ const LLSD response = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), std::string(), LLSD(), LLSD("ok"), LLSD());
+ ensure("response keeps id", response.has("id"));
+ ensure("response omits method", !response.has("method"));
+ ensure_equals("response keeps result", response["result"].asString(), std::string("ok"));
+
+ // Error response with an undefined id (e.g. a parse error): method
+ // is empty, so id is kept even though undefined -- serializes as null.
+ LLSD error_obj;
+ error_obj["code"] = -32700;
+ error_obj["message"] = "Parse error";
+ const LLSD error_response = LLJSONRPCConnection::makeEnvelope(LLSD(), std::string(), LLSD(), LLSD(), error_obj);
+ ensure("error response keeps id even when undefined", error_response.has("id"));
+ ensure("that id is null", error_response["id"].isUndefined());
+ ensure("error response omits method", !error_response.has("method"));
+ }
+
+ template<> template<>
+ void jsonrpcws_object::test<17>()
+ {
+ set_test_name("onClose fails every pending request with CONNECTION_CLOSED, exactly once");
+
+ TestJSONRPCConnection conn;
+
+ int callback_count_a = 0;
+ int callback_count_b = 0;
+ conn.testInjectPendingRequest(
+ "req_a",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD& result, const LLSD& error)
+ {
+ ++callback_count_a;
+ ensure("no result on connection close", result.isUndefined());
+ ensure_equals("connection closed error code", error["code"].asInteger(), LLJSONRPCConnection::RPCError::CONNECTION_CLOSED);
+ });
+ conn.testInjectPendingRequest(
+ "req_b",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD&, const LLSD& error)
+ {
+ ++callback_count_b;
+ ensure_equals("connection closed error code", error["code"].asInteger(), LLJSONRPCConnection::RPCError::CONNECTION_CLOSED);
+ });
+ ensure_equals("two requests pending", conn.testPendingRequestCount(), (size_t)2);
+
+ conn.onClose();
+
+ ensure_equals("first callback fires exactly once", callback_count_a, 1);
+ ensure_equals("second callback fires exactly once", callback_count_b, 1);
+ ensure_equals("nothing left pending", conn.testPendingRequestCount(), (size_t)0);
+ }
+}
diff --git a/indra/llcorehttp/tests/llcorehttp_test.cpp b/indra/llcorehttp/tests/llcorehttp_test.cpp
index 65d7fe93a9..cc3c241a96 100644
--- a/indra/llcorehttp/tests/llcorehttp_test.cpp
+++ b/indra/llcorehttp/tests/llcorehttp_test.cpp
@@ -45,6 +45,7 @@
#include "test_httpheaders.hpp"
#include "test_httprequestqueue.hpp"
#include "test_jsonrpcws.hpp"
+#include "test_websocketmgr.hpp"
#include "_httpservice.h"
#include "llproxy.h"
diff --git a/indra/llcorehttp/tests/test_jsonrpcws.hpp b/indra/llcorehttp/tests/test_jsonrpcws.hpp
index 6cf7932aa0..a40d0117b8 100644
--- a/indra/llcorehttp/tests/test_jsonrpcws.hpp
+++ b/indra/llcorehttp/tests/test_jsonrpcws.hpp
@@ -24,6 +24,7 @@ public:
using LLJSONRPCConnection::testInjectPendingRequest;
using LLJSONRPCConnection::testPendingRequestCount;
using LLJSONRPCConnection::testSweepTimeouts;
+ using LLJSONRPCConnection::setAuthenticated;
};
}
@@ -91,6 +92,7 @@ namespace tut
set_test_name("processMessage dispatches notification handler");
TestJSONRPCConnection conn;
+ conn.setAuthenticated(true);
bool called = false;
LLSD seen_id;
@@ -121,6 +123,7 @@ namespace tut
set_test_name("sweepTimeouts expires overdue callbacks");
TestJSONRPCConnection conn;
+ conn.setAuthenticated(true);
bool callback_called = false;
conn.testInjectPendingRequest(
@@ -141,6 +144,60 @@ namespace tut
ensure("timeout callback should be called", callback_called);
ensure_equals("pending request should be removed", conn.testPendingRequestCount(), (size_t)0);
}
+
+ template<> template<>
+ void JSONRPCWSTestObjectType::test<6>()
+ {
+ set_test_name("a connection not yet authenticated dispatches nothing, and everything once it is");
+
+ TestJSONRPCConnection conn;
+ ensure("unauthenticated unless told otherwise", !conn.isAuthenticated());
+
+ int calls = 0;
+ conn.registerMethod("object.list",
+ [&](const std::string&, const LLSD&, const LLSD&) -> LLSD
+ {
+ ++calls;
+ return LLSD();
+ });
+
+ conn.setAuthenticated(false);
+ ensure("not authenticated", !conn.isAuthenticated());
+ const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(request);
+ const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD());
+ conn.processMessage(notification);
+ ensure_equals("neither a request nor a notification reaches the handler", calls, 0);
+
+ conn.setAuthenticated(true);
+ conn.processMessage(request);
+ ensure_equals("dispatched once authenticated", calls, 1);
+ }
+
+ template<> template<>
+ void JSONRPCWSTestObjectType::test<7>()
+ {
+ set_test_name("a connection not yet authenticated still takes the answers to its own calls");
+
+ TestJSONRPCConnection conn;
+ conn.setAuthenticated(false);
+
+ LLSD answer;
+ conn.testInjectPendingRequest(
+ "rpc_1",
+ LLTimer::getTotalSeconds() + 60.0,
+ [&](const LLSD& result, const LLSD& error)
+ {
+ ensure("no error", error.isUndefined());
+ answer = result;
+ });
+
+ LLSD result;
+ result["challenge_response"] = "secret";
+ conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("rpc_1"), std::string(), LLSD(), result, LLSD()));
+ ensure_equals("the answer reached the caller", answer["challenge_response"].asString(), std::string("secret"));
+ ensure("and it is answered", conn.testPendingRequestCount() == 0);
+ }
}
#endif \ No newline at end of file
diff --git a/indra/llcorehttp/tests/test_websocketmgr.hpp b/indra/llcorehttp/tests/test_websocketmgr.hpp
new file mode 100644
index 0000000000..38b3d79cbf
--- /dev/null
+++ b/indra/llcorehttp/tests/test_websocketmgr.hpp
@@ -0,0 +1,157 @@
+/**
+ * @file test_websocketmgr.hpp
+ * @brief A WebSocket server lets programs in and turns pages away.
+ *
+ * $LicenseInfo:firstyear=2026&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2026, Linden Research, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ *
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
+ * $/LicenseInfo$
+ */
+
+#ifndef TEST_LLCORE_WEBSOCKETMGR_H_
+#define TEST_LLCORE_WEBSOCKETMGR_H_
+
+#include "llwebsocketmgr.h"
+
+#include <boost/asio/io_context.hpp>
+#include <boost/asio/ip/tcp.hpp>
+#include <boost/asio/read_until.hpp>
+#include <boost/asio/streambuf.hpp>
+#include <boost/asio/write.hpp>
+
+#include <chrono>
+#include <istream>
+#include <random>
+
+namespace
+{
+ // A port nothing on this machine is listening on, as far as can be
+ // told; zero where none turned up.
+ U16 unusedPort()
+ {
+ std::random_device random;
+ for (int tries = 0; tries < 20; ++tries)
+ {
+ const U16 port = static_cast<U16>(40000 + random() % 20000);
+ boost::asio::io_context io;
+ boost::asio::ip::tcp::acceptor acceptor(io);
+ boost::system::error_code ec;
+ acceptor.open(boost::asio::ip::tcp::v4(), ec);
+ if (!ec)
+ {
+ acceptor.bind(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec);
+ }
+ if (!ec)
+ {
+ return port;
+ }
+ }
+ return 0;
+ }
+
+ // The HTTP status a WebSocket upgrade request from this origin -- none,
+ // where empty -- is answered with: 101 where it would open, -1 where
+ // nothing answered at all. The request is what a browser or a program
+ // sends, written out, so that what is tested is what the server says.
+ int upgradeFrom(U16 port, const std::string& origin)
+ {
+ boost::asio::io_context io;
+ boost::asio::ip::tcp::socket socket(io);
+ boost::system::error_code ec;
+ socket.connect(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec);
+ if (ec)
+ {
+ return -1;
+ }
+
+ std::string request = "GET / HTTP/1.1\r\n"
+ "Host: 127.0.0.1:" + std::to_string(port) + "\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+ "Sec-WebSocket-Version: 13\r\n";
+ if (!origin.empty())
+ {
+ request += "Origin: " + origin + "\r\n";
+ }
+ request += "\r\n";
+ boost::asio::write(socket, boost::asio::buffer(request), ec);
+ if (ec)
+ {
+ return -1;
+ }
+
+ // The status line, or nothing within the time.
+ boost::asio::streambuf response;
+ bool answered = false;
+ boost::asio::async_read_until(socket, response, "\r\n",
+ [&](const boost::system::error_code& error, std::size_t) { answered = !error; });
+ io.run_for(std::chrono::seconds(10));
+ socket.close(ec);
+ if (!answered)
+ {
+ return -1;
+ }
+ std::istream line(&response);
+ std::string version;
+ int status = -1;
+ line >> version >> status;
+ return status;
+ }
+}
+
+namespace tut
+{
+ struct WebsocketMgrTestData
+ {
+ };
+
+ typedef test_group<WebsocketMgrTestData> WebsocketMgrTestGroupType;
+ typedef WebsocketMgrTestGroupType::object WebsocketMgrTestObjectType;
+ WebsocketMgrTestGroupType WebsocketMgrTestGroup("LLWebsocketMgr Tests");
+
+ template<> template<>
+ void WebsocketMgrTestObjectType::test<1>()
+ {
+ set_test_name("a program, which sends no origin, is let in; a page, which always does, is refused");
+
+ const U16 port = unusedPort();
+ if (!port)
+ {
+ skip("no port free to listen on");
+ }
+ const std::string name = "origin_test";
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ ensure("added", manager.addServer(std::make_shared<LLWebsocketMgr::WSServer>(name, port, true)));
+ ensure("started", manager.startServer(name));
+
+ const int program = upgradeFrom(port, std::string());
+ const int page = upgradeFrom(port, "https://example.com");
+ const int opaque = upgradeFrom(port, "null");
+ const int local = upgradeFrom(port, "http://127.0.0.1:8080");
+ manager.removeServer(name);
+
+ ensure_equals("no origin: in", program, 101);
+ ensure_equals("a web page: refused", page, 403);
+ ensure_equals("a page with an opaque origin, a sandboxed frame or a file: refused", opaque, 403);
+ ensure_equals("a page served from this machine: refused", local, 403);
+ }
+}
+
+#endif
diff --git a/indra/llcorehttp/tests/testwsclient.h b/indra/llcorehttp/tests/testwsclient.h
new file mode 100644
index 0000000000..587934f2b6
--- /dev/null
+++ b/indra/llcorehttp/tests/testwsclient.h
@@ -0,0 +1,170 @@
+/**
+ * @file testwsclient.h
+ * @brief Minimal WebSocket client for driving a server under test
+ *
+ * $LicenseInfo:firstyear=2026&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2026, Linden Research, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ *
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
+ * $/LicenseInfo$
+ */
+
+#ifndef LL_TESTWSCLIENT_H
+#define LL_TESTWSCLIENT_H
+
+// MSVC does not report __cplusplus >= 201103L without /Zc:__cplusplus, so
+// websocketpp's own C++11 detection would otherwise fall back to
+// boost::random_device, which nothing here links.
+#define _WEBSOCKETPP_CPP11_RANDOM_DEVICE_
+
+#include <websocketpp/config/asio_no_tls_client.hpp>
+#include <websocketpp/client.hpp>
+
+#include <chrono>
+#include <condition_variable>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <vector>
+
+// A minimal WebSocket client for driving a server under test: connects,
+// optionally with an Origin header, and can send and collect messages.
+class TestWSClient
+{
+public:
+ using client_t = websocketpp::client<websocketpp::config::asio_client>;
+
+ TestWSClient()
+ {
+ mClient.clear_access_channels(websocketpp::log::alevel::all);
+ mClient.clear_error_channels(websocketpp::log::elevel::all);
+ mClient.init_asio();
+
+ mClient.set_open_handler([this](websocketpp::connection_hdl hdl)
+ {
+ std::lock_guard<std::mutex> lock(mMutex);
+ mHandle = hdl;
+ mOpened = true;
+ mCondVar.notify_all();
+ });
+ mClient.set_fail_handler([this](websocketpp::connection_hdl)
+ {
+ std::lock_guard<std::mutex> lock(mMutex);
+ mFailed = true;
+ mCondVar.notify_all();
+ });
+ mClient.set_close_handler([this](websocketpp::connection_hdl)
+ {
+ std::lock_guard<std::mutex> lock(mMutex);
+ mClosed = true;
+ mCondVar.notify_all();
+ });
+ mClient.set_message_handler([this](websocketpp::connection_hdl, client_t::message_ptr msg)
+ {
+ std::lock_guard<std::mutex> lock(mMutex);
+ mReceived.push_back(msg->get_payload());
+ mCondVar.notify_all();
+ });
+ }
+
+ ~TestWSClient()
+ {
+ close();
+ mClient.stop();
+ if (mThread.joinable())
+ {
+ mThread.join();
+ }
+ }
+
+ // Initiates a close and waits for the ack, so teardown looks like a
+ // normal closure instead of a dropped socket. Safe to call more than
+ // once, and safe if the connection never opened.
+ void close(std::chrono::seconds timeout = std::chrono::seconds(2))
+ {
+ std::unique_lock<std::mutex> lock(mMutex);
+ if (!mOpened || mClosed)
+ {
+ return;
+ }
+ websocketpp::lib::error_code ec;
+ mClient.close(mHandle, websocketpp::close::status::normal, "test done", ec);
+ mCondVar.wait_for(lock, timeout, [this]() { return mClosed; });
+ }
+
+ // Connects to 127.0.0.1:port, optionally with an Origin header, and
+ // waits up to timeout for the handshake to open or fail.
+ // force_origin_header sends "Origin:" with an empty value instead of
+ // omitting the header entirely, for origin.empty().
+ bool connect(U16 port, const std::string& origin, bool force_origin_header = false,
+ std::chrono::seconds timeout = std::chrono::seconds(5))
+ {
+ websocketpp::lib::error_code ec;
+ client_t::connection_ptr con = mClient.get_connection(
+ "ws://127.0.0.1:" + std::to_string(port) + "/", ec);
+ if (ec)
+ {
+ return false;
+ }
+ if (!origin.empty() || force_origin_header)
+ {
+ con->append_header("Origin", origin);
+ }
+ mClient.connect(con);
+ mThread = std::thread([this]() { mClient.run(); });
+
+ std::unique_lock<std::mutex> lock(mMutex);
+ return mCondVar.wait_for(lock, timeout, [this]() { return mOpened || mFailed; }) && mOpened;
+ }
+
+ bool send(const std::string& message)
+ {
+ websocketpp::lib::error_code ec;
+ mClient.send(mHandle, message, websocketpp::frame::opcode::text, ec);
+ return !ec;
+ }
+
+ // Waits up to timeout for at least one message to arrive, then
+ // removes and returns it; empty where none arrived in time.
+ std::string waitForMessage(std::chrono::seconds timeout = std::chrono::seconds(5))
+ {
+ std::unique_lock<std::mutex> lock(mMutex);
+ if (!mCondVar.wait_for(lock, timeout, [this]() { return !mReceived.empty(); }))
+ {
+ return std::string();
+ }
+ std::string message = mReceived.front();
+ mReceived.erase(mReceived.begin());
+ return message;
+ }
+
+ bool isOpen() const { return mOpened && !mClosed; }
+
+private:
+ client_t mClient;
+ websocketpp::connection_hdl mHandle;
+ std::thread mThread;
+ std::mutex mMutex;
+ std::condition_variable mCondVar;
+ bool mOpened{ false };
+ bool mFailed{ false };
+ bool mClosed{ false };
+ std::vector<std::string> mReceived;
+};
+
+#endif // LL_TESTWSCLIENT_H
diff --git a/indra/llcorehttp/tests/wsserver_test.cpp b/indra/llcorehttp/tests/wsserver_test.cpp
new file mode 100644
index 0000000000..62fa66a9cd
--- /dev/null
+++ b/indra/llcorehttp/tests/wsserver_test.cpp
@@ -0,0 +1,438 @@
+/**
+ * @file wsserver_test.cpp
+ * @brief Security-focused unit tests for LLWebsocketMgr::WSServer
+ *
+ * $LicenseInfo:firstyear=2026&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2026, Linden Research, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ *
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
+ * $/LicenseInfo$
+ */
+
+#include "linden_common.h"
+
+#include "llwebsocketmgr.h"
+
+#include "testwsclient.h"
+#include "../test/lltut.h"
+
+#include <boost/asio/io_context.hpp>
+#include <boost/asio/ip/tcp.hpp>
+#include <boost/asio/ip/udp.hpp>
+#include <boost/asio/read_until.hpp>
+#include <boost/asio/streambuf.hpp>
+#include <boost/asio/write.hpp>
+
+#include <atomic>
+#include <chrono>
+#include <istream>
+#include <random>
+#include <thread>
+
+namespace
+{
+ // A port nothing on this machine is listening on, as far as can be
+ // told; zero where none turned up.
+ U16 unusedPort()
+ {
+ std::random_device random;
+ for (int tries = 0; tries < 20; ++tries)
+ {
+ const U16 port = static_cast<U16>(40000 + random() % 20000);
+ boost::asio::io_context io;
+ boost::asio::ip::tcp::acceptor acceptor(io);
+ boost::system::error_code ec;
+ acceptor.open(boost::asio::ip::tcp::v4(), ec);
+ if (!ec)
+ {
+ acceptor.bind(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec);
+ }
+ if (!ec)
+ {
+ return port;
+ }
+ }
+ return 0;
+ }
+
+ // The HTTP status a WebSocket upgrade request from this origin -- none,
+ // where empty -- is answered with: 101 where it would open, -1 where
+ // nothing answered at all.
+ // force_origin_header sends "Origin:" with an empty value instead of
+ // omitting the header entirely, for origin.empty().
+ int upgradeFrom(U16 port, const std::string& origin, bool force_origin_header = false)
+ {
+ boost::asio::io_context io;
+ boost::asio::ip::tcp::socket socket(io);
+ boost::system::error_code ec;
+ socket.connect(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec);
+ if (ec)
+ {
+ return -1;
+ }
+
+ std::string request = "GET / HTTP/1.1\r\n"
+ "Host: 127.0.0.1:" + std::to_string(port) + "\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+ "Sec-WebSocket-Version: 13\r\n";
+ if (!origin.empty() || force_origin_header)
+ {
+ request += "Origin: " + origin + "\r\n";
+ }
+ request += "\r\n";
+ boost::asio::write(socket, boost::asio::buffer(request), ec);
+ if (ec)
+ {
+ return -1;
+ }
+
+ boost::asio::streambuf response;
+ bool answered = false;
+ boost::asio::async_read_until(socket, response, "\r\n",
+ [&](const boost::system::error_code& error, std::size_t) { answered = !error; });
+ io.run_for(std::chrono::seconds(10));
+ socket.close(ec);
+ if (!answered)
+ {
+ return -1;
+ }
+ std::istream line(&response);
+ std::string version;
+ int status = -1;
+ line >> version >> status;
+ return status;
+ }
+
+ // Polls a predicate until it is true or the deadline passes. A client
+ // seeing its own handshake complete says nothing about whether the
+ // server's own thread has finished its side of onOpen bookkeeping yet.
+ template <typename Predicate>
+ bool waitUntil(Predicate predicate, std::chrono::milliseconds timeout = std::chrono::milliseconds(2000))
+ {
+ const auto deadline = std::chrono::steady_clock::now() + timeout;
+ while (!predicate())
+ {
+ if (std::chrono::steady_clock::now() >= deadline)
+ {
+ return false;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ }
+ return true;
+ }
+
+ // The machine's own non-loopback IPv4 address, as seen when routing
+ // toward the internet (no packet is actually sent); empty where none
+ // could be found, such as a loopback-only sandbox.
+ std::string localNonLoopbackAddress()
+ {
+ boost::asio::io_context io;
+ boost::asio::ip::udp::socket socket(io);
+ boost::system::error_code ec;
+ socket.connect(boost::asio::ip::udp::endpoint(boost::asio::ip::make_address("8.8.8.8"), 53), ec);
+ if (ec)
+ {
+ return std::string();
+ }
+ return socket.local_endpoint(ec).address().to_string();
+ }
+
+ // Whether a TCP connection to host:port succeeds within a short time;
+ // refused or silently dropped are both treated as "no".
+ bool canConnect(const std::string& host, U16 port)
+ {
+ boost::asio::io_context io;
+ boost::asio::ip::tcp::socket socket(io);
+ boost::system::error_code result = boost::asio::error::would_block;
+ socket.async_connect(
+ boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address(host), port),
+ [&](const boost::system::error_code& ec) { result = ec; });
+ io.run_for(std::chrono::seconds(3));
+ boost::system::error_code close_ec;
+ socket.close(close_ec);
+ return !result;
+ }
+
+ // Records what a server actually did, so tests can assert on lifecycle
+ // callbacks the base class leaves as no-ops.
+ class TestWSServer : public LLWebsocketMgr::WSServer
+ {
+ public:
+ using LLWebsocketMgr::WSServer::WSServer;
+
+ std::atomic<int> mOpenedCount{ 0 };
+ std::atomic<int> mClosedCount{ 0 };
+
+ // Empty where the base policy (reject any Origin) should apply;
+ // otherwise the one origin this test server lets in.
+ std::string mAllowedOrigin;
+
+ void onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t&) override
+ {
+ ++mOpenedCount;
+ }
+
+ void onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t&) override
+ {
+ ++mClosedCount;
+ }
+
+ bool acceptOrigin(const std::string& origin) const override
+ {
+ if (!mAllowedOrigin.empty() && origin == mAllowedOrigin)
+ {
+ return true;
+ }
+ return WSServer::acceptOrigin(origin);
+ }
+ };
+}
+
+namespace tut
+{
+ struct wsserver_data
+ {
+ U16 mPort;
+ std::string mName;
+
+ wsserver_data() :
+ mPort(unusedPort()),
+ mName("wsserver_test")
+ {
+ }
+
+ ~wsserver_data()
+ {
+ LLWebsocketMgr::instance().removeServer(mName);
+ }
+ };
+
+ typedef test_group<wsserver_data> wsserver_test;
+ typedef wsserver_test::object wsserver_object;
+ wsserver_test wsserver_testcase("LLWebsocketMgr::WSServer");
+
+ template<> template<>
+ void wsserver_object::test<1>()
+ {
+ set_test_name("a program, which sends no Origin header, is let in");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("no origin: connection opens", upgradeFrom(mPort, std::string()), 101);
+ ensure("server clears the abruptly closed raw connection", waitUntil([&]() { return server->getConnectionCount() == 0; }));
+
+ TestWSClient client;
+ ensure("no origin: client connects over the network", client.connect(mPort, std::string()));
+ ensure("server sees the open connection", waitUntil([&]() { return server->getConnectionCount() == 1; }));
+
+ client.close();
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<2>()
+ {
+ set_test_name("a page, which sends an Origin header, is refused");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("a web page: refused", upgradeFrom(mPort, "https://example.com"), 403);
+
+ TestWSClient client;
+ ensure("a web page: client never opens over the network", !client.connect(mPort, "https://example.com"));
+ ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0);
+
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<3>()
+ {
+ set_test_name("a page with an opaque origin (a sandboxed frame or a file) is refused");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("an opaque origin: refused", upgradeFrom(mPort, "null"), 403);
+
+ TestWSClient client;
+ ensure("an opaque origin: client never opens over the network", !client.connect(mPort, "null"));
+ ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0);
+
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<4>()
+ {
+ set_test_name("a page served from this machine is refused");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("a page on this machine: refused", upgradeFrom(mPort, "http://127.0.0.1:8080"), 403);
+
+ TestWSClient client;
+ ensure("a page on this machine: client never opens over the network", !client.connect(mPort, "http://127.0.0.1:8080"));
+ ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0);
+
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<5>()
+ {
+ set_test_name("an Origin header present but empty is treated the same as none");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("empty origin: connection opens", upgradeFrom(mPort, std::string(), true), 101);
+ ensure("server clears the abruptly closed raw connection", waitUntil([&]() { return server->getConnectionCount() == 0; }));
+
+ TestWSClient client;
+ ensure("empty origin: client connects over the network", client.connect(mPort, std::string(), true));
+ ensure("server sees the open connection", waitUntil([&]() { return server->getConnectionCount() == 1; }));
+
+ client.close();
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<7>()
+ {
+ set_test_name("local_only binds to loopback only; the machine's own LAN address is unreachable");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ const std::string lan_address = localNonLoopbackAddress();
+ if (lan_address.empty())
+ {
+ skip("no non-loopback network interface to test against");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure("loopback: reachable", canConnect("127.0.0.1", mPort));
+ ensure("LAN address: unreachable", !canConnect(lan_address, mPort));
+
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<8>()
+ {
+ set_test_name("a refused connection never reaches onConnectionOpened, and an accepted one does");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<TestWSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ ensure_equals("a web page: refused", upgradeFrom(mPort, "https://example.com"), 403);
+ ensure_equals("refused: onConnectionOpened never fires", server->mOpenedCount.load(), 0);
+
+ TestWSClient client;
+ ensure("no origin: client connects over the network", client.connect(mPort, std::string()));
+ ensure("accepted: onConnectionOpened fires once", waitUntil([&]() { return server->mOpenedCount.load() == 1; }));
+
+ client.close();
+ manager.removeServer(mName);
+ }
+
+ template<> template<>
+ void wsserver_object::test<9>()
+ {
+ set_test_name("multiple simultaneous connections are each tracked independently");
+
+ if (!mPort)
+ {
+ skip("no port free to listen on");
+ }
+
+ LLWebsocketMgr& manager = LLWebsocketMgr::instance();
+ auto server = std::make_shared<TestWSServer>(mName, mPort, true);
+ ensure("added", manager.addServer(server));
+ ensure("started", manager.startServer(mName));
+
+ TestWSClient client_a;
+ TestWSClient client_b;
+ TestWSClient client_c;
+ ensure("first client connects", client_a.connect(mPort, std::string()));
+ ensure("second client connects", client_b.connect(mPort, std::string()));
+ ensure("third client connects", client_c.connect(mPort, std::string()));
+
+ ensure("server counts all three connections", waitUntil([&]() { return server->getConnectionCount() == 3; }));
+ ensure("onConnectionOpened fires once per connection", waitUntil([&]() { return server->mOpenedCount.load() == 3; }));
+
+ client_a.close();
+ client_b.close();
+ client_c.close();
+ manager.removeServer(mName);
+ }
+}
diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp
index 178b03d4da..0a3e09fec0 100644
--- a/indra/newview/llscripteditorws.cpp
+++ b/indra/newview/llscripteditorws.cpp
@@ -73,6 +73,12 @@
#include <array>
+#include <openssl/rand.h>
+
+#if LL_WINDOWS
+#include <aclapi.h>
+#endif
+
namespace
{
// Per-operation timeouts (seconds) for coroutine-based async RPC handlers.
@@ -81,6 +87,11 @@ namespace
constexpr F32 NOTECARD_UPLOAD_TIMEOUT = 30.0f;
constexpr F32 ITEM_CREATE_TIMEOUT = 30.0f;
+ // Seconds a client has to answer the handshake: plenty to read a
+ // file, and no longer than that is a connection kept that has not
+ // proven itself.
+ constexpr F64 HANDSHAKE_TIMEOUT = 30.0;
+
// Linkset flush coalescing delays (seconds).
constexpr F32 LINKSET_ADD_FLUSH_DELAY = 5.0f;
constexpr F32 LINKSET_REMOVE_FLUSH_DELAY = 0.2f;
@@ -1256,7 +1267,7 @@ void LLScriptEditorWSServer::broadcastLanguageChange()
if (isRunning())
{
- broadcastNotification("language.syntax.change", params);
+ notifyAll("language.syntax.change", params);
}
}
}
@@ -2365,7 +2376,7 @@ void LLScriptEditorWSServer::notifyConnection(U32 connection_id, const std::stri
if (it != mActiveConnections.end())
{
auto connection = it->second.lock();
- if (connection)
+ if (connection && connection->isAuthenticated())
{
connection->notify(method, params);
}
@@ -2385,7 +2396,7 @@ void LLScriptEditorWSServer::notifyAll(const std::string& method, const LLSD& pa
for (const auto& pair : mActiveConnections)
{
auto connection = pair.second.lock();
- if (connection)
+ if (connection && connection->isAuthenticated())
{
connection->sendMessage(payload);
}
@@ -2816,11 +2827,14 @@ void LLScriptEditorWSConnection::sendHandshake()
handshake["agent_id"] = gAgent.getID();
handshake["agent_name"] = gAgentUsername;
- std::string challenge_file = generateChallenge();
- if (!challenge_file.empty())
+ const Challenge challenge = writeChallenge();
+ if (challenge.mFile.empty())
{
- handshake["challenge"] = challenge_file;
+ // Nothing to prove itself by, so nothing it may do.
+ sendDisconnect(DisconnectReason::INTERNAL_ERROR, "Unable to issue a challenge");
+ return;
}
+ handshake["challenge"] = challenge.mFile;
LLSD languages = LLSD::emptyArray();
languages.append("lsl");
@@ -2839,22 +2853,33 @@ void LLScriptEditorWSConnection::sendHandshake()
wptr_t that = weak_from_this();
- // Send session.handshake method call and the response
- call("session.handshake", handshake, [that](const LLSD& result, const LLSD& error) {
- if (error.isUndefined())
+ // The answer comes once, whichever way: from the client, as the
+ // time runs out, or as the connection closes.
+ const LLSD sent = call(
+ "session.handshake", handshake,
+ [that, challenge](const LLSD& result, const LLSD& error)
{
+ // Whatever the answer, the file has done its work.
+ LLFile::remove(challenge.mFile);
auto self = that.lock();
- if (self)
+ if (!self)
{
- self->handleHandshakeResponse(result);
+ return;
}
- }
- else
- {
- LL_WARNS("ScriptEditorWS") << "Handshake failed: "
- << error["message"].asString() << LL_ENDL;
- }
- });
+ if (error.isDefined())
+ {
+ self->handleHandshakeError(error);
+ return;
+ }
+ self->handleHandshakeResponse(result, challenge.mSecret);
+ },
+ HANDSHAKE_TIMEOUT);
+ if (sent.isUndefined())
+ {
+ LLFile::remove(challenge.mFile);
+ handleHandshakeError(LLSD().with("code", LLJSONRPCConnection::RPCError::INTERNAL_ERROR).with("message", "Handshake not sent"));
+ return;
+ }
LL_INFOS("ScriptEditorWS") << "Sent handshake call to new editor client" << LL_ENDL;
}
@@ -2886,40 +2911,36 @@ void LLScriptEditorWSConnection::sendDisconnect(DisconnectReason reason, const s
closeConnection(1000, message);
}
-void LLScriptEditorWSConnection::handleHandshakeResponse(const LLSD& result)
+void LLScriptEditorWSConnection::handleHandshakeError(const LLSD& error)
{
- LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
- LL_INFOS("ScriptEditorWS") << "Processing handshake response from client" << LL_ENDL;
-
- mClientName = result["client_name"].asString();
- mClientVersion = result["client_version"].asString();
- mProtocolVersion = result["protocol_version"].asString();
-
- // Validate challenge response (if a challenge was issued).
- const bool challenge_issued = mChallenge.notNull();
- bool valid_response = true;
- if (challenge_issued)
+ const S32 code = error["code"].asInteger();
+ if (code == LLJSONRPCConnection::RPCError::CONNECTION_CLOSED)
{
- valid_response = result.has("challenge_response") &&
- (result["challenge_response"].asUUID() == mChallenge);
- mChallenge.setNull();
+ // Gone already.
+ return;
}
+ LL_WARNS("ScriptEditorWS") << "Handshake failed: " << error["message"].asString() << LL_ENDL;
+ sendDisconnect(code == LLJSONRPCConnection::RPCError::REQUEST_TIMEOUT ? DisconnectReason::TIMEOUT : DisconnectReason::PROTOCOL_ERROR,
+ "Handshake failed");
+}
- // Always clean up the temporary challenge file if one was created,
- // regardless of validation outcome.
- if (!mChallengeFile.empty())
- {
- LLFile::remove(mChallengeFile);
- mChallengeFile.clear();
- }
+void LLScriptEditorWSConnection::handleHandshakeResponse(const LLSD& result, const LLUUID& secret)
+{
+ LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV;
+ LL_INFOS("ScriptEditorWS") << "Processing handshake response from client" << LL_ENDL;
- if (challenge_issued && !valid_response)
+ // Only something that could read the file knows what it says.
+ if (!result.isMap() || result["challenge_response"].asUUID() != secret)
{
LL_WARNS("ScriptEditorWS") << "Invalid or missing challenge response from client" << LL_ENDL;
sendDisconnect(DisconnectReason::PROTOCOL_ERROR, "Invalid challenge response");
return;
}
+ mClientName = result["client_name"].asString();
+ mClientVersion = result["client_version"].asString();
+ mProtocolVersion = result["protocol_version"].asString();
+
if (mProtocolVersion != "1.0")
{
LL_WARNS("ScriptEditorWS") << "Protocol version mismatch. Expected: 1.0, Got: "
@@ -2945,29 +2966,134 @@ void LLScriptEditorWSConnection::handleHandshakeResponse(const LLSD& result)
}
}
+ // Let in before it is told so: what it asks on hearing it must be
+ // answered.
+ setAuthenticated(true);
notify("session.ok");
LL_INFOS("ScriptEditorWS") << "Handshake completed successfully." << LL_ENDL;
}
-std::string LLScriptEditorWSConnection::generateChallenge()
+// static
+LLScriptEditorWSConnection::Challenge LLScriptEditorWSConnection::writeChallenge()
{
- mChallenge.generate();
+ // A secret nobody could work out: a new id is made of the time and
+ // the network card's address, which are anybody's to know.
+ Challenge challenge;
+ if (RAND_bytes(challenge.mSecret.mData, UUID_BYTES) != 1)
+ {
+ LL_WARNS("ScriptEditorWS") << "Unable to make a challenge secret" << LL_ENDL;
+ return {};
+ }
- mChallengeFile = std::string(LLFile::tmpdir()) + "sl_script_challenge_" + mChallenge.asString() + ".tmp";
+ // Named for nothing it holds, made new rather than written over
+ // anything, or through a link, already by that name, and readable by
+ // the user alone: the name goes to whoever connects, and the temp
+ // folder can be everyone's.
+ const std::string file = LLFile::tmpdir() + "sl_script_challenge_" + LLUUID::generateNewID().asString() + ".tmp";
+ const std::string text = challenge.mSecret.asString();
+ if (!writeUserOnlyFile(file, text))
+ {
+ LL_WARNS("ScriptEditorWS") << "Unable to write challenge file " << file << LL_ENDL;
+ return {};
+ }
+
+ challenge.mFile = file;
+ return challenge;
+}
- llofstream file(mChallengeFile.c_str());
- if (!file.is_open())
+// static
+bool LLScriptEditorWSConnection::writeUserOnlyFile(const std::string& file, const std::string& text)
+{
+#if LL_WINDOWS
+ // LLFile::open() ignores the POSIX permission bits on Windows -- its
+ // decode_attributes() only maps them to a normal/read-only file
+ // attribute, never to an ACL -- so a mode like 0600 there does not
+ // keep other local accounts from reading the file. Build a DACL
+ // explicitly, granting only the current user's SID access.
+ HANDLE token = nullptr;
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
{
- LL_WARNS("ScriptEditorWS") << "Unable to open challenge file: " << mChallengeFile << LL_ENDL;
- mChallenge.setNull();
- mChallengeFile.clear();
- return std::string();
+ LL_WARNS("ScriptEditorWS") << "Unable to open the process token for the challenge file's ACL" << LL_ENDL;
+ return false;
+ }
+
+ DWORD needed = 0;
+ GetTokenInformation(token, TokenUser, nullptr, 0, &needed);
+ std::vector<BYTE> user_info(needed);
+ const bool got_user = needed != 0 && GetTokenInformation(token, TokenUser, user_info.data(), needed, &needed);
+ CloseHandle(token);
+ if (!got_user)
+ {
+ LL_WARNS("ScriptEditorWS") << "Unable to read the current user's SID for the challenge file's ACL" << LL_ENDL;
+ return false;
+ }
+ PSID user_sid = reinterpret_cast<PTOKEN_USER>(user_info.data())->User.Sid;
+
+ EXPLICIT_ACCESSW ea = {};
+ ea.grfAccessPermissions = GENERIC_READ | GENERIC_WRITE | DELETE;
+ ea.grfAccessMode = SET_ACCESS;
+ ea.grfInheritance = NO_INHERITANCE;
+ ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
+ ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
+ ea.Trustee.ptstrName = reinterpret_cast<LPWSTR>(user_sid);
+
+ PACL dacl = nullptr;
+ if (SetEntriesInAclW(1, &ea, nullptr, &dacl) != ERROR_SUCCESS)
+ {
+ LL_WARNS("ScriptEditorWS") << "Unable to build an ACL for the challenge file" << LL_ENDL;
+ return false;
}
- file << mChallenge;
- file.close();
+ SECURITY_DESCRIPTOR sd;
+ const bool sd_ok = InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION) &&
+ SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE);
+ if (!sd_ok)
+ {
+ LL_WARNS("ScriptEditorWS") << "Unable to build a security descriptor for the challenge file" << LL_ENDL;
+ LocalFree(dacl);
+ return false;
+ }
+
+ SECURITY_ATTRIBUTES sa = {};
+ sa.nLength = sizeof(sa);
+ sa.lpSecurityDescriptor = &sd;
+ sa.bInheritHandle = FALSE;
+
+ const std::wstring wfile = ll_convert<std::wstring>(file);
+ // CREATE_NEW: fails if the file already exists, matching LLFile::noreplace.
+ HANDLE handle = CreateFileW(wfile.c_str(), GENERIC_WRITE, 0, &sa, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
+ LocalFree(dacl);
+ if (handle == INVALID_HANDLE_VALUE)
+ {
+ return false;
+ }
- return mChallengeFile;
+ DWORD written = 0;
+ const bool ok = WriteFile(handle, text.data(), static_cast<DWORD>(text.size()), &written, nullptr) &&
+ written == text.size();
+ CloseHandle(handle);
+ if (!ok)
+ {
+ LLFile::remove(file);
+ }
+ return ok;
+#else
+ // POSIX open() honors the mode bits directly.
+ std::error_code ec;
+ LLFile out(file, LLFile::out | LLFile::noreplace, ec, 0600);
+ if (ec)
+ {
+ return false;
+ }
+ const bool written = out.write(text.data(), static_cast<S64>(text.size()), ec) == static_cast<S64>(text.size()) && !ec;
+ const bool closed = out.close(ec) == 0;
+ if (!written || !closed)
+ {
+ LLFile::remove(file);
+ return false;
+ }
+ return true;
+#endif
}
diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h
index 4498ca9107..9919800ebe 100644
--- a/indra/newview/llscripteditorws.h
+++ b/indra/newview/llscripteditorws.h
@@ -69,6 +69,10 @@ public:
LLScriptEditorWSConnection(const LLWebsocketMgr::WSServer::ptr_t server, const LLWebsocketMgr::connection_h& handle) :
LLJSONRPCConnection(server, handle)
{
+ // Anything that can reach the port can connect: nothing it asks
+ // is answered until it has proven it can read the user's files.
+ setAuthenticated(false);
+
// Reserve id 0 as the "unassigned" sentinel used by EditorSubscription;
// on wrap, skip past it.
U32 id;
@@ -93,12 +97,32 @@ public:
private:
using string_set_t = std::set<std::string>;
+
+ // What the client proves itself with: a secret written to a file only
+ // the user can read, under a name that says nothing of it. The client
+ // is told where the file is and answers with what it says.
+ struct Challenge
+ {
+ LLUUID mSecret;
+ std::string mFile;
+ };
+ // Empty where no secret could be made or written.
+ static Challenge writeChallenge();
+
+ // Writes text to a new file at path, readable only by the current
+ // user. On Windows this means an explicit ACL, since LLFile::open()
+ // there ignores POSIX permission bits entirely. Fails if the file
+ // already exists.
+ static bool writeUserOnlyFile(const std::string& path, const std::string& text);
+
/**
* @brief Handle the handshake response from the client
* @param result The response data from the client containing client information
+ * @param secret What the challenge file said, which the client must answer with
*/
- void handleHandshakeResponse(const LLSD& result);
- std::string generateChallenge();
+ void handleHandshakeResponse(const LLSD& result, const LLUUID& secret);
+ // The handshake refused, unanswered in time, or never sent.
+ void handleHandshakeError(const LLSD& error);
void sendHandshake();
LLScriptEdContainer* getEditor() const;
@@ -114,8 +138,6 @@ private:
std::string mScriptLanguage; ///< Programming language of the script (lsl, luau, etc.)
string_set_t mLanguages; ///< Set of supported scripting languages
string_set_t mFeatures; ///< Active client features (live_sync, compilation, etc.)
- LLUUID mChallenge;
- std::string mChallengeFile; ///< Temporary file used for challenge-response verification
static std::atomic<U32> sNextConnectionID;
};
@@ -152,6 +174,11 @@ private:
* ## Security Considerations
*
* - Server binds to localhost only by default for security
+ * - A connection from a browser -- any upgrade request with an Origin -- is
+ * refused, so no web page, the viewer's own included, can reach it
+ * - Nothing a client asks is answered, and nothing is sent it, until it
+ * has answered the handshake's challenge with what a file only the user
+ * can read says; a wrong answer, or none within 30 seconds, closes it
* - JSON-RPC 2.0 structured protocol with validation
* - Rate limiting handled by base JSON-RPC server
* - Error handling with standardized JSON-RPC error codes
diff --git a/indra/newview/tests/llscripteditorws_test.cpp b/indra/newview/tests/llscripteditorws_test.cpp
new file mode 100644
index 0000000000..4cf1bde034
--- /dev/null
+++ b/indra/newview/tests/llscripteditorws_test.cpp
@@ -0,0 +1,130 @@
+/**
+ * @file llscripteditorws_test.cpp
+ * @brief Headless test fixture for LLScriptEditorWSServer with fake connection
+ *
+ * $LicenseInfo:firstyear=2026&license=viewerlgpl$
+ * Second Life Viewer Source Code
+ * Copyright (C) 2026, Linden Research, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation;
+ * version 2.1 of the License only.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ *
+ * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
+ * $/LicenseInfo$
+ */
+
+#include "../llviewerprecompiledheaders.h"
+#include "../llscripteditorws.h"
+#include "../test/lltut.h"
+
+namespace
+{
+class FakeScriptEditorWSConnection : public LLScriptEditorWSConnection
+{
+public:
+ explicit FakeScriptEditorWSConnection(const LLWebsocketMgr::WSServer::ptr_t& server)
+ : LLScriptEditorWSConnection(server, LLWebsocketMgr::connection_h())
+ {
+ }
+
+ using LLJSONRPCConnection::processMessage;
+};
+
+class HeadlessScriptEditorWSServer : public LLScriptEditorWSServer
+{
+public:
+ HeadlessScriptEditorWSServer()
+ : LLScriptEditorWSServer("script_editor_ws_test_server", 0, true)
+ {
+ }
+
+ using LLScriptEditorWSServer::setupConnectionMethods;
+};
+}
+
+namespace tut
+{
+ struct script_editor_ws_test_data
+ {
+ std::shared_ptr<HeadlessScriptEditorWSServer> mServer;
+ std::shared_ptr<FakeScriptEditorWSConnection> mConnection;
+
+ script_editor_ws_test_data()
+ {
+ mServer = std::make_shared<HeadlessScriptEditorWSServer>();
+ mConnection = std::make_shared<FakeScriptEditorWSConnection>(mServer);
+ mServer->setupConnectionMethods(mConnection);
+ }
+ };
+
+ typedef test_group<script_editor_ws_test_data> script_editor_ws_test_group;
+ typedef script_editor_ws_test_group::object script_editor_ws_test_object;
+ script_editor_ws_test_group script_editor_ws_tests("LLScriptEditorWSServer");
+
+ template<> template<>
+ void script_editor_ws_test_object::test<1>()
+ {
+ set_test_name("headless fixture constructs server and fake connection");
+ ensure("server should be created", mServer != nullptr);
+ ensure("fake connection should be created", mConnection != nullptr);
+ }
+
+ template<> template<>
+ void script_editor_ws_test_object::test<2>()
+ {
+ set_test_name("fake connection can drive JSON-RPC notification dispatch");
+
+ bool called = false;
+ mConnection->registerMethod(
+ "fixture.echo",
+ [&](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD
+ {
+ called = true;
+ ensure_equals("method should match", method, "fixture.echo");
+ ensure("notification id should be undefined", id.isUndefined());
+ ensure_equals("payload should be forwarded", params["value"].asString(), "ok");
+ return LLSD();
+ });
+
+ LLSD params;
+ params["value"] = "ok";
+ LLSD notification = LLJSONRPCConnection::makeEnvelope(
+ LLSD(),
+ "fixture.echo",
+ params,
+ LLSD(),
+ LLSD());
+
+ mConnection->processMessage(notification);
+ ensure("fixture handler should be invoked", called);
+ }
+
+ template<> template<>
+ void script_editor_ws_test_object::test<3>()
+ {
+ set_test_name("fixture includes default session.ping handler");
+
+ LLSD params;
+ params["timestamp"] = 123;
+ LLSD notification = LLJSONRPCConnection::makeEnvelope(
+ LLSD(),
+ "system.ping",
+ params,
+ LLSD(),
+ LLSD());
+
+ mConnection->processMessage(notification);
+ ensure("session.ping dispatch should complete in headless mode", true);
+ }
+}