diff options
Diffstat (limited to 'indra')
| -rw-r--r-- | indra/llcorehttp/CMakeLists.txt | 5 | ||||
| -rw-r--r-- | indra/llcorehttp/lljsonrpcws.cpp | 29 | ||||
| -rw-r--r-- | indra/llcorehttp/lljsonrpcws.h | 21 | ||||
| -rw-r--r-- | indra/llcorehttp/llwebsocketmgr.cpp | 29 | ||||
| -rw-r--r-- | indra/llcorehttp/llwebsocketmgr.h | 15 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/jsonrpcws_test.cpp | 553 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/llcorehttp_test.cpp | 1 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/test_jsonrpcws.hpp | 55 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/test_websocketmgr.hpp | 155 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/testwsclient.h | 170 | ||||
| -rw-r--r-- | indra/llcorehttp/tests/wsserver_test.cpp | 436 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.cpp | 147 | ||||
| -rw-r--r-- | indra/newview/llscripteditorws.h | 29 | ||||
| -rw-r--r-- | indra/newview/tests/llscripteditorws_test.cpp | 130 |
14 files changed, 1706 insertions, 69 deletions
diff --git a/indra/llcorehttp/CMakeLists.txt b/indra/llcorehttp/CMakeLists.txt index c2c4078ff0..bdc2582eef 100644 --- a/indra/llcorehttp/CMakeLists.txt +++ b/indra/llcorehttp/CMakeLists.txt @@ -120,6 +120,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}) @@ -144,6 +145,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..b21ede1f6b 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; }; } @@ -141,6 +142,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..efc557cc40 --- /dev/null +++ b/indra/llcorehttp/tests/test_websocketmgr.hpp @@ -0,0 +1,155 @@ +/** + * @file test_websocketmgr.hpp + * @brief A WebSocket server lets programs in and turns pages away. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye <rye@alchemyviewer.org> + * + * 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 + * $/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..4d442e3a45 --- /dev/null +++ b/indra/llcorehttp/tests/wsserver_test.cpp @@ -0,0 +1,436 @@ +/** + * @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); + + 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); + + 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..ddc3e25d7b 100644 --- a/indra/newview/llscripteditorws.cpp +++ b/indra/newview/llscripteditorws.cpp @@ -73,6 +73,8 @@ #include <array> +#include <openssl/rand.h> + namespace { // Per-operation timeouts (seconds) for coroutine-based async RPC handlers. @@ -81,6 +83,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 +1263,7 @@ void LLScriptEditorWSServer::broadcastLanguageChange() if (isRunning()) { - broadcastNotification("language.syntax.change", params); + notifyAll("language.syntax.change", params); } } } @@ -2365,7 +2372,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 +2392,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 +2823,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 +2849,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 +2907,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 +2962,49 @@ 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(); - - mChallengeFile = std::string(LLFile::tmpdir()) + "sl_script_challenge_" + mChallenge.asString() + ".tmp"; - - llofstream file(mChallengeFile.c_str()); - if (!file.is_open()) + // 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 open challenge file: " << mChallengeFile << LL_ENDL; - mChallenge.setNull(); - mChallengeFile.clear(); - return std::string(); + LL_WARNS("ScriptEditorWS") << "Unable to make a challenge secret" << LL_ENDL; + return {}; } - file << mChallenge; - file.close(); + // 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(); + std::error_code ec; + LLFile out(file, LLFile::out | LLFile::noreplace, ec, 0600); + if (ec) + { + // Whatever is there by that name is somebody else's, and stays. + LL_WARNS("ScriptEditorWS") << "Unable to make challenge file " << file << ": " << ec.message() << LL_ENDL; + return {}; + } + const bool written = out.write(text.data(), static_cast<S64>(text.size()), ec) == static_cast<S64>(text.size()) && !ec; + if (out.close(ec) != 0 || !written) + { + LL_WARNS("ScriptEditorWS") << "Unable to write challenge file " << file << LL_ENDL; + LLFile::remove(file); + return {}; + } - return mChallengeFile; + challenge.mFile = file; + return challenge; } diff --git a/indra/newview/llscripteditorws.h b/indra/newview/llscripteditorws.h index 4498ca9107..7a215f5715 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,26 @@ 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(); + /** * @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 +132,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 +168,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..79de2dd9a6 --- /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(), + "session.ping", + params, + LLSD(), + LLSD()); + + mConnection->processMessage(notification); + ensure("session.ping dispatch should complete in headless mode", true); + } +} |
