summaryrefslogtreecommitdiff
path: root/indra/newview
diff options
context:
space:
mode:
authorJonathan "Geenz" Goodman <geenz@lindenlab.com>2026-10-02 12:30:42 -0400
committerGitHub <noreply@github.com>2026-10-02 12:30:42 -0400
commit0ebee4d8fa2997744dcd882b4be604aa6c14c312 (patch)
tree334d05075af85eebca7477078e6ec95e78a510c3 /indra/newview
parent01786bf78c825cce2639c482bfc97ae5f3161a1b (diff)
parent48e1f6f25b952eeb35e891cd73265e652198cae6 (diff)
Merge pull request #6396 from secondlife/rider/issue-710
Rider/issue 710
Diffstat (limited to 'indra/newview')
-rw-r--r--indra/newview/llscripteditorws.cpp232
-rw-r--r--indra/newview/llscripteditorws.h35
-rw-r--r--indra/newview/tests/llscripteditorws_test.cpp130
3 files changed, 340 insertions, 57 deletions
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);
+ }
+}