From d557bdde727c6b28ab33ebb4d85fdba531a85947 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 4 Nov 2026 05:13:14 -0500 Subject: [PATCH 1/3] A peer that never pings no longer grows a circuit's list of reliable packets The list of reliable packets kept for duplicate suppression shrinks only when the peer's StartPingCheck names its oldest unacked. A peer that sends reliable packets and no pings grew it by a map node per number, with 2^24 numbers before they repeat. It now holds 65536. The one that goes is the oldest number, the first past the highest seen, since numbers wrap, or else the lowest. A number forgotten this way is handled again if a resend of it comes, which takes 65536 newer reliable packets arriving with no ping between them. Co-Authored-By: Claude Opus 5.5 --- indra/llmessage/llcircuit.cpp | 20 +++++++++ indra/llmessage/llcircuit.h | 2 + indra/llmessage/message.cpp | 2 +- indra/llmessage/tests/llcircuit_test.cpp | 52 +++++++++++++++++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/indra/llmessage/llcircuit.cpp b/indra/llmessage/llcircuit.cpp index cac0529fb8..848307f57b 100644 --- a/indra/llmessage/llcircuit.cpp +++ b/indra/llmessage/llcircuit.cpp @@ -627,6 +627,26 @@ bool LLCircuitData::checkDuplicate(TPACKETID packetnum, bool resent) } +void LLCircuitData::recordReliablePacket(TPACKETID packetnum) +{ + mRecentlyReceivedReliablePackets[packetnum] = LLMessageSystem::getMessageTimeUsecs(); + + // Only the peer's pings let the list shrink (clearDuplicateList), so a + // peer that sends reliable packets and no pings would grow it without + // end. The oldest number is the first past the highest, since numbers + // wrap, or the lowest if nothing is past it. + if (mRecentlyReceivedReliablePackets.size() > LL_MAX_RECENT_RELIABLE_PACKETS) + { + packet_time_map::iterator oldest = mRecentlyReceivedReliablePackets.upper_bound(mHighestPacketID); + if (oldest == mRecentlyReceivedReliablePackets.end()) + { + oldest = mRecentlyReceivedReliablePackets.begin(); + } + mRecentlyReceivedReliablePackets.erase(oldest); + } +} + + void LLCircuit::dumpResends() { circuit_data_map::iterator end = mCircuitData.end(); diff --git a/indra/llmessage/llcircuit.h b/indra/llmessage/llcircuit.h index 8ea96652ad..4dd816c3c8 100644 --- a/indra/llmessage/llcircuit.h +++ b/indra/llmessage/llcircuit.h @@ -50,6 +50,7 @@ const F32Milliseconds LL_AVERAGED_PING_MIN(100); // increased to avoid retran const U32Milliseconds INITIAL_PING_VALUE_MSEC(1000); // initial value for the ping delay, or for ping delay for an unknown circuit const TPACKETID LL_MAX_OUT_PACKET_ID = 0x01000000; +const size_t LL_MAX_RECENT_RELIABLE_PACKETS = 65536; // how many reliable packets a circuit remembers for duplicate suppression const int LL_ERR_CIRCUIT_GONE = -23017; const int LL_ERR_TCP_TIMEOUT = -23016; @@ -171,6 +172,7 @@ class LLCircuitData TPACKETID nextPacketOutID(); void setPacketInID(TPACKETID id); void checkPacketInID(TPACKETID id, bool receive_resent); + void recordReliablePacket(TPACKETID packetnum); // Remember a reliable packet for duplicate suppression void setPingDelay(U32Milliseconds ping); bool checkCircuitTimeout(); // Return false if the circuit is dead and should be cleaned up diff --git a/indra/llmessage/message.cpp b/indra/llmessage/message.cpp index 1f07159e32..cdd59e2b07 100644 --- a/indra/llmessage/message.cpp +++ b/indra/llmessage/message.cpp @@ -744,7 +744,7 @@ bool LLMessageSystem::checkMessages(LockMessageChecker&, S64 frame_count, if (cdp && recv_reliable) { // Add to the recently received list for duplicate suppression - cdp->mRecentlyReceivedReliablePackets[mCurrentRecvPacketID] = getMessageTimeUsecs(); + cdp->recordReliablePacket(mCurrentRecvPacketID); // Put it onto the list of packets to be acked, unless // it was acked as it came in diff --git a/indra/llmessage/tests/llcircuit_test.cpp b/indra/llmessage/tests/llcircuit_test.cpp index ed8f6cb68d..3d81d8f72d 100644 --- a/indra/llmessage/tests/llcircuit_test.cpp +++ b/indra/llmessage/tests/llcircuit_test.cpp @@ -204,7 +204,13 @@ namespace ll_test static void handled(LLCircuitData& circuit, TPACKETID id) { circuit.checkPacketInID(id, false); - circuit.mRecentlyReceivedReliablePackets[id] = LLMessageSystem::getMessageTimeUsecs(); + circuit.recordReliablePacket(id); + } + + // How many reliable packets the circuit remembers. + static size_t recentCount(const LLCircuitData& circuit) + { + return circuit.mRecentlyReceivedReliablePackets.size(); } static bool checkDuplicate(LLCircuitData& circuit, TPACKETID id, bool resent) @@ -402,6 +408,50 @@ namespace CHECK(CircuitProbe::checkDuplicate(circuit, 7, true)); } + TEST_CASE("llcircuit: a peer that never pings leaves only the newest reliable packets remembered", "[llcircuit]") + { + StubMessageSystemScope message_system; + LLCircuitData circuit(LLHost(), 0, HEARTBEAT, TIMEOUT); + + const TPACKETID PAST_THE_BOUND = 100; + const TPACKETID COUNT = static_cast(LL_MAX_RECENT_RELIABLE_PACKETS) + PAST_THE_BOUND; + for (TPACKETID id = 0; id < COUNT; ++id) + { + CircuitProbe::handled(circuit, id); + } + + CHECK(CircuitProbe::recentCount(circuit) == LL_MAX_RECENT_RELIABLE_PACKETS); + CHECK_FALSE(CircuitProbe::checkDuplicate(circuit, 0, true)); + CHECK_FALSE(CircuitProbe::checkDuplicate(circuit, PAST_THE_BOUND - 1, true)); + CHECK(CircuitProbe::checkDuplicate(circuit, PAST_THE_BOUND, true)); + CHECK(CircuitProbe::checkDuplicate(circuit, COUNT - 1, true)); + } + + TEST_CASE("llcircuit: the reliable packets forgotten across a wrap are the ones before it", "[llcircuit]") + { + StubMessageSystemScope message_system; + // Enough numbers before the wrap to fill the list, and a few after. + const TPACKETID FIRST = LL_MAX_OUT_PACKET_ID - static_cast(LL_MAX_RECENT_RELIABLE_PACKETS); + LLCircuitData circuit(LLHost(), FIRST, HEARTBEAT, TIMEOUT); + + for (TPACKETID id = FIRST; id < LL_MAX_OUT_PACKET_ID; ++id) + { + CircuitProbe::handled(circuit, id); + } + const TPACKETID AFTER_THE_WRAP = 10; + for (TPACKETID id = 0; id < AFTER_THE_WRAP; ++id) + { + CircuitProbe::handled(circuit, id); + } + + CHECK(CircuitProbe::recentCount(circuit) == LL_MAX_RECENT_RELIABLE_PACKETS); + CHECK(CircuitProbe::checkDuplicate(circuit, 0, true)); + CHECK(CircuitProbe::checkDuplicate(circuit, AFTER_THE_WRAP - 1, true)); + CHECK_FALSE(CircuitProbe::checkDuplicate(circuit, FIRST, true)); + CHECK_FALSE(CircuitProbe::checkDuplicate(circuit, FIRST + AFTER_THE_WRAP - 1, true)); + CHECK(CircuitProbe::checkDuplicate(circuit, FIRST + AFTER_THE_WRAP, true)); + } + TEST_CASE("llcircuit: a ping keeps a number handled moments ago that its sender has had acked", "[llcircuit]") { StubMessageSystemScope message_system; From cc2838504c417fb8d3ba3965a11f81ed22a78f10 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 4 Nov 2026 05:13:18 -0500 Subject: [PATCH 2/3] A secure login no longer follows a redirect off TLS A login's next attempt sends the user's credentials to wherever the last answer's next_url says, and a login over https would follow it to http. It now ends with a failure that says why (LoginFailedInsecureRedirect). A plain login, as on a local grid, isn't held to this. The same could happen a level down. libcurl follows redirects for XML-RPC, and a 307 or 308 sends a POST's body on as it is, so one from an https login server to http sent the credentials in the clear. XML-RPC over https now sets the new HttpOptions::setRedirectsHttpsOnly, which limits libcurl's redirects to https and fails the request with CURLE_UNSUPPORTED_PROTOCOL otherwise. Other requests follow redirects as before. The test peer has no TLS, so the tests run the refusal and the default against a redirect to http. A redirect to https under the option isn't run. Co-Authored-By: Claude Opus 5.5 --- indra/llcorehttp/_httpoprequest.cpp | 4 + indra/llcorehttp/httpoptions.cpp | 6 + indra/llcorehttp/httpoptions.h | 10 ++ indra/llcorehttp/tests/httprequest_test.cpp | 96 ++++++++++++ .../llcorehttp/tests/test_llcorehttp_peer.py | 8 +- indra/newview/llxmlrpctransaction.cpp | 5 + .../newview/skins/default/xui/en/strings.xml | 1 + indra/viewer_components/login/lllogin.cpp | 30 +++- .../login/tests/lllogin_test.cpp | 142 ++++++++++++++++++ 9 files changed, 297 insertions(+), 5 deletions(-) diff --git a/indra/llcorehttp/_httpoprequest.cpp b/indra/llcorehttp/_httpoprequest.cpp index 21652bbdda..bbb7f118a3 100644 --- a/indra/llcorehttp/_httpoprequest.cpp +++ b/indra/llcorehttp/_httpoprequest.cpp @@ -557,6 +557,10 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) lastModified = (curl_off_t)mReqOptions->getLastModified(); } check_curl_easy_setopt(mCurlHandle, CURLOPT_FOLLOWLOCATION, follow_redirect); + if (mReqOptions && mReqOptions->getRedirectsHttpsOnly()) + { + check_curl_easy_setopt(mCurlHandle, CURLOPT_REDIR_PROTOCOLS, long(CURLPROTO_HTTPS)); + } check_curl_easy_setopt(mCurlHandle, CURLOPT_SSL_VERIFYPEER, sslPeerV); check_curl_easy_setopt(mCurlHandle, CURLOPT_SSL_VERIFYHOST, sslHostV); diff --git a/indra/llcorehttp/httpoptions.cpp b/indra/llcorehttp/httpoptions.cpp index 5abd28e211..696abbc8e4 100644 --- a/indra/llcorehttp/httpoptions.cpp +++ b/indra/llcorehttp/httpoptions.cpp @@ -44,6 +44,7 @@ HttpOptions::HttpOptions() : mMaxRetryBackoff(HTTP_RETRY_BACKOFF_MAX_DEFAULT), mUseRetryAfter(HTTP_USE_RETRY_AFTER_DEFAULT), mFollowRedirects(true), + mRedirectsHttpsOnly(false), mVerifyPeer(sDefaultVerifyPeer), mVerifyHost(false), mDNSCacheTimeout(-1L), @@ -105,6 +106,11 @@ void HttpOptions::setFollowRedirects(bool follow_redirect) mFollowRedirects = follow_redirect; } +void HttpOptions::setRedirectsHttpsOnly(bool https_only) +{ + mRedirectsHttpsOnly = https_only; +} + void HttpOptions::setSSLVerifyPeer(bool verify) { mVerifyPeer = verify; diff --git a/indra/llcorehttp/httpoptions.h b/indra/llcorehttp/httpoptions.h index d50c5470ae..1d15c31401 100644 --- a/indra/llcorehttp/httpoptions.h +++ b/indra/llcorehttp/httpoptions.h @@ -140,6 +140,15 @@ class HttpOptions return mFollowRedirects; } + /// Fails the request if a redirect it would follow goes anywhere but + /// https, for a request whose body and headers must stay on TLS. + /// Default: false + void setRedirectsHttpsOnly(bool https_only); + bool getRedirectsHttpsOnly() const + { + return mRedirectsHttpsOnly; + } + /// Instructs the LLCore::HTTPRequest to verify that the exchanged security /// certificate is authentic. /// Default: sDefaultVerifyPeer @@ -201,6 +210,7 @@ class HttpOptions HttpTime mMaxRetryBackoff; bool mUseRetryAfter; bool mFollowRedirects; + bool mRedirectsHttpsOnly; bool mVerifyPeer; bool mVerifyHost; int mDNSCacheTimeout; diff --git a/indra/llcorehttp/tests/httprequest_test.cpp b/indra/llcorehttp/tests/httprequest_test.cpp index 2c4f6e483d..5e940988cb 100644 --- a/indra/llcorehttp/tests/httprequest_test.cpp +++ b/indra/llcorehttp/tests/httprequest_test.cpp @@ -890,6 +890,102 @@ TEST_CASE_METHOD(HttpRequestTestData, "HttpRequest Tests: a GET to the peer is a } +namespace +{ + +// Sends a GET to the peer's redirect with these options, waits for its answer, +// checks it against wanted, and stops the service again. +void checkRedirectAnswer(HttpRequestTestData & state, const HttpOptions::ptr_t & options, const HttpStatus & wanted) +{ + ScopedCurlInit ready; + + std::string url_base(get_base_url()); + + TestHandler2 handler(&state, "handler"); + LLCore::HttpHandler::ptr_t handlerp(&handler, NoOpDeletor); + state.mHandlerCalls = 0; + + HttpRequest * req = NULL; + + try + { + HttpRequest::createService(); + HttpRequest::startThread(); + req = new HttpRequest(); + + state.mStatus = wanted; + HttpHandle handle = req->requestGet(HttpRequest::DEFAULT_POLICY_ID, + url_base + "redirect/", + options, + HttpHeaders::ptr_t(), + handlerp); + REQUIRE(handle != LLCORE_HTTP_HANDLE_INVALID); + + int count(0); + int limit(LOOP_COUNT_LONG); + while (count++ < limit && state.mHandlerCalls < 1) + { + req->update(1000000); + usleep(LOOP_SLEEP_INTERVAL); + } + UNSCOPED_INFO("Request executed in reasonable time"); + CHECK(count < limit); + CHECK(state.mHandlerCalls == 1); + + state.mStatus = HttpStatus(); + handle = req->requestStopThread(handlerp); + REQUIRE(handle != LLCORE_HTTP_HANDLE_INVALID); + + count = 0; + limit = LOOP_COUNT_LONG; + while (count++ < limit && state.mHandlerCalls < 2) + { + req->update(1000000); + usleep(LOOP_SLEEP_INTERVAL); + } + UNSCOPED_INFO("Second request executed in reasonable time"); + CHECK(count < limit); + + count = 0; + limit = LOOP_COUNT_SHORT; + while (count++ < limit && ! HttpService::isStopped()) + { + usleep(LOOP_SLEEP_INTERVAL); + } + CHECK(HttpService::isStopped()); + + delete req; + req = NULL; + + HttpRequest::destroyService(); + } + catch (...) + { + stop_thread(req); + delete req; + HttpRequest::destroyService(); + throw; + } +} + +} + + +TEST_CASE_METHOD(HttpRequestTestData, "HttpRequest Tests: a GET follows a redirect to plain http by default", "[HttpRequest_Tests]") +{ + checkRedirectAnswer(*this, std::make_shared(), HttpStatus(200)); +} + + +TEST_CASE_METHOD(HttpRequestTestData, "HttpRequest Tests: a GET that keeps its redirects on https fails one to plain http", "[HttpRequest_Tests]") +{ + HttpOptions::ptr_t options(std::make_shared()); + options->setRedirectsHttpsOnly(true); + + checkRedirectAnswer(*this, options, HttpStatus(HttpStatus::EXT_CURL_EASY, CURLE_UNSUPPORTED_PROTOCOL)); +} + + TEST_CASE_METHOD(HttpRequestTestData, "HttpRequest Tests: a GET with a byte range sends its Range header and takes a 200 for the whole body", "[HttpRequest_Tests]") { ScopedCurlInit ready; diff --git a/indra/llcorehttp/tests/test_llcorehttp_peer.py b/indra/llcorehttp/tests/test_llcorehttp_peer.py index 227f0d056c..6b5dd21f15 100755 --- a/indra/llcorehttp/tests/test_llcorehttp_peer.py +++ b/indra/llcorehttp/tests/test_llcorehttp_peer.py @@ -76,6 +76,7 @@ class TestHTTPRequestHandler(BaseHTTPRequestHandler): -- '/503/4/' "Retry-After: (*#*(@*(@(")" -- '/503/5/' "Retry-After: aklsjflajfaklsfaklfasfklasdfklasdgahsdhgasdiogaioshdgo" -- '/503/6/' "Retry-After: 1 2 3 4 5 6 7 8 9 10" + - '/redirect/' 307 response sending the request on to '/' Some combinations make no sense, there's no effort to protect you from that. @@ -150,7 +151,12 @@ def answer(self, data, withdata=True): if "/sleep/" in self.path: time.sleep(30) - if "/503/" in self.path: + if "/redirect/" in self.path: + self.send_response(307) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + elif "/503/" in self.path: # Tests for various kinds of 'Retry-After' header parsing body = None if "/503/0/" in self.path: diff --git a/indra/newview/llxmlrpctransaction.cpp b/indra/newview/llxmlrpctransaction.cpp index a9183109ef..9e4b8d57f1 100644 --- a/indra/newview/llxmlrpctransaction.cpp +++ b/indra/newview/llxmlrpctransaction.cpp @@ -27,6 +27,7 @@ #include "llviewerprecompiledheaders.h" // include this to get winsock2 because openssl attempts to include winsock1 #include "llwin32headers.h" +#include #include #include #include "llsecapi.h" @@ -213,6 +214,10 @@ LLXMLRPCTransaction::Impl::Impl httpOpts->setSSLVerifyPeer(vefifySSLCert); httpOpts->setSSLVerifyHost(vefifySSLCert); + // The body holds the login's credentials or a payment's details, and a 307 + // or 308 sends it on as it is. + httpOpts->setRedirectsHttpsOnly(boost::algorithm::istarts_with(mURI, "https://")); + // LLRefCounted starts with a 1 ref, so don't add a ref in the smart pointer httpHeaders = std::make_shared(); diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml index cc6176db0e..c4571de845 100644 --- a/indra/newview/skins/default/xui/en/strings.xml +++ b/indra/newview/skins/default/xui/en/strings.xml @@ -136,6 +136,7 @@ https://www.alchemyviewer.org/downloads If you feel this is an error, please contact Support. Optional viewer update available: [VERSION] Required viewer update: [VERSION] + The login server tried to send your login to an address that isn't secure, so the login was stopped. This agent is already logged in. Sorry! We couldn't log you in. diff --git a/indra/viewer_components/login/lllogin.cpp b/indra/viewer_components/login/lllogin.cpp index bcc664fb23..bc485949b8 100644 --- a/indra/viewer_components/login/lllogin.cpp +++ b/indra/viewer_components/login/lllogin.cpp @@ -30,6 +30,7 @@ #include "lllogin.h" +#include #include #include "llcoros.h" @@ -41,6 +42,15 @@ #include "alsessioncapture.h" #endif +namespace +{ + // Whether a URI goes over TLS. A scheme's case doesn't matter. + bool isSecureURI(const std::string& uri) + { + return boost::algorithm::istarts_with(uri, "https://"); + } +} + //********************* // LLLogin // *NOTE:Mani - Is this Impl needed now that the state machine runs the show? @@ -201,13 +211,25 @@ void LLLogin::Impl::loginCoro(std::string uri, LLSD login_params) break; } - sendProgressEvent("offline", "indeterminate", mAuthResponse["responses"]); - // Here the login service at the current URI is redirecting us // to some other URI ("indeterminate" -- why not "redirect"?). // The response should contain another uri to try, with its - // own auth method. - request["uri"] = mAuthResponse["responses"]["next_url"].asString(); + // own auth method. The next attempt sends the same credentials, + // so a login that went over TLS doesn't let an answer take them + // off it. + const std::string next_uri = mAuthResponse["responses"]["next_url"].asString(); + if (isSecureURI(request["uri"].asString()) && !isSecureURI(next_uri)) + { + LL_WARNS("LLLogin") << "Not following a redirect of a secure login to '" << next_uri << "'" << LL_ENDL; + sendProgressEvent("offline", "fail.login", + LLSDMap("reason", "InsecureRedirect") + ("message_id", "LoginFailedInsecureRedirect")); + return; + } + + sendProgressEvent("offline", "indeterminate", mAuthResponse["responses"]); + + request["uri"] = next_uri; request["method"] = mAuthResponse["responses"]["next_method"].asString(); } // loop back to try the redirected URI diff --git a/indra/viewer_components/login/tests/lllogin_test.cpp b/indra/viewer_components/login/tests/lllogin_test.cpp index 42b4734e81..9a570ded2c 100644 --- a/indra/viewer_components/login/tests/lllogin_test.cpp +++ b/indra/viewer_components/login/tests/lllogin_test.cpp @@ -134,6 +134,7 @@ class FakeXMLRPCListener: public LLEventTrackable LLSD mEvent; bool mImmediateResponse; LLSD mResponse; + size_t mRequests{ 0 }; Debug mDebug; public: @@ -161,10 +162,15 @@ class FakeXMLRPCListener: public LLEventTrackable mResponse = r; } + // How many requests have come, and the last of them. + size_t requests() const { return mRequests; } + LLSD lastRequest() const { return mEvent; } + bool handle_event(const LLSD& event) { mDebug(STRINGIZE("FakeXMLRPCListener called!: " << event)); mEvent = event; + ++mRequests; if(mImmediateResponse) { sendReply(); @@ -343,4 +349,140 @@ namespace CHECK(listener.lastEvent()["state"].asString() == "offline"); } + + // A login's answer that sends it on to another URI. + LLSD redirectTo(const std::string& uri) + { + LLSD data; + data["status"] = "Complete"; + data["errorcode"] = 0; + data["error"] = "dummy response"; + data["transfer_rate"] = 0; + data["responses"]["login"] = "indeterminate"; + data["responses"]["next_url"] = uri; + data["responses"]["next_method"] = "login_to_simulator"; + return data; + } + + // Starts a login at from_uri, answers it with a redirect to to_uri, and + // gives what the login does next: the event that ends it, or that of its + // next attempt, after which listener and the requests can be read. + LLSD afterRedirect(const std::string& from_uri, const std::string& to_uri, + LLLogin& login, LoginListener& listener, FakeXMLRPCListener& xmlrpc) + { + LLSD credentials; + credentials["first"] = "who"; + credentials["last"] = "what"; + credentials["passwd"] = "secret"; + + login.connect(from_uri, credentials); + llcoro::suspend(); + REQUIRE(xmlrpc.requests() == 1); + + xmlrpc.setResponse(redirectTo(to_uri)); + xmlrpc.sendReply(); + return listener.waitFor( + "the login to end or try again", + [&listener]()->bool + { + const LLSD event(listener.lastEvent()); + return event["change"].asString() == "fail.login" + || event["data"]["attempt"].asInteger() == 2; + }); + } + + // Answers a login's attempt with a refusal, so that its coroutine ends. + void endLogin(LoginListener& listener, FakeXMLRPCListener& xmlrpc) + { + LLSD data; + data["status"] = "Complete"; + data["errorcode"] = 0; + data["error"] = "dummy response"; + data["transfer_rate"] = 0; + data["responses"]["login"] = "false"; + xmlrpc.setResponse(data); + xmlrpc.sendReply(); + listener.waitFor( + "the login to end", + [&listener]()->bool{ return listener.lastEvent()["change"].asString() == "fail.login"; }); + } + + TEST_CASE_METHOD(llviewerlogin_data, "LLViewerLogin: a secure login isn't sent on to a plain URI", "[LLViewerLogin]") + { + LLEventStream xmlrpcPump("LLXMLRPCTransaction"); + FakeXMLRPCListener dummyXMLRPC("dummy_xmlrpc"); + LLTempBoundListener conn1 = dummyXMLRPC.listenTo(xmlrpcPump); + + LLLogin login; + LoginListener listener("test_ear"); + LLTempBoundListener conn2 = listener.listenTo(login.getEventPump()); + + const LLSD event(afterRedirect("https://login.bar.com/", "http://login.bar.com/", + login, listener, dummyXMLRPC)); + + CHECK(event["state"].asString() == "offline"); + CHECK(event["change"].asString() == "fail.login"); + CHECK(event["data"]["reason"].asString() == "InsecureRedirect"); + CHECK(event["data"]["message_id"].asString() == "LoginFailedInsecureRedirect"); + CHECK(dummyXMLRPC.requests() == 1); + } + + TEST_CASE_METHOD(llviewerlogin_data, "LLViewerLogin: a secure login isn't sent on to a URI with no scheme", "[LLViewerLogin]") + { + LLEventStream xmlrpcPump("LLXMLRPCTransaction"); + FakeXMLRPCListener dummyXMLRPC("dummy_xmlrpc"); + LLTempBoundListener conn1 = dummyXMLRPC.listenTo(xmlrpcPump); + + LLLogin login; + LoginListener listener("test_ear"); + LLTempBoundListener conn2 = listener.listenTo(login.getEventPump()); + + // The scheme's case doesn't make the login any less secure. + const LLSD event(afterRedirect("HTTPS://login.bar.com/", "login.bar.com", + login, listener, dummyXMLRPC)); + + CHECK(event["change"].asString() == "fail.login"); + CHECK(dummyXMLRPC.requests() == 1); + } + + TEST_CASE_METHOD(llviewerlogin_data, "LLViewerLogin: a secure login is sent on to another secure URI", "[LLViewerLogin]") + { + LLEventStream xmlrpcPump("LLXMLRPCTransaction"); + FakeXMLRPCListener dummyXMLRPC("dummy_xmlrpc"); + LLTempBoundListener conn1 = dummyXMLRPC.listenTo(xmlrpcPump); + + LLLogin login; + LoginListener listener("test_ear"); + LLTempBoundListener conn2 = listener.listenTo(login.getEventPump()); + + const LLSD event(afterRedirect("https://login.bar.com/", "HTTPS://login2.bar.com/", + login, listener, dummyXMLRPC)); + + CHECK(event["change"].asString() == "authenticating"); + CHECK(dummyXMLRPC.requests() == 2); + CHECK(dummyXMLRPC.lastRequest()["uri"].asString() == "HTTPS://login2.bar.com/"); + CHECK(dummyXMLRPC.lastRequest()["method"].asString() == "login_to_simulator"); + + endLogin(listener, dummyXMLRPC); + } + + TEST_CASE_METHOD(llviewerlogin_data, "LLViewerLogin: a plain login is sent on to a plain URI", "[LLViewerLogin]") + { + LLEventStream xmlrpcPump("LLXMLRPCTransaction"); + FakeXMLRPCListener dummyXMLRPC("dummy_xmlrpc"); + LLTempBoundListener conn1 = dummyXMLRPC.listenTo(xmlrpcPump); + + LLLogin login; + LoginListener listener("test_ear"); + LLTempBoundListener conn2 = listener.listenTo(login.getEventPump()); + + const LLSD event(afterRedirect("http://localhost:8002/", "http://localhost:8003/", + login, listener, dummyXMLRPC)); + + CHECK(event["change"].asString() == "authenticating"); + CHECK(dummyXMLRPC.requests() == 2); + CHECK(dummyXMLRPC.lastRequest()["uri"].asString() == "http://localhost:8003/"); + + endLogin(listener, dummyXMLRPC); + } } From 647ad881a71571d297540dea0e0b0a102162ef53 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 4 Nov 2026 05:13:21 -0500 Subject: [PATCH 3/3] A teleport inside the region with no answer is given up on A teleport inside the agent's region sets TELEPORT_LOCAL, and gTeleportDisplay stays false until TeleportLocal comes, so update_tp_display, which gives up on a teleport past its time, doesn't run for it. If neither TeleportLocal nor TeleportFailed came, the state stayed for the rest of the session, and teleportCore refused every later teleport as one made while teleporting. The timer now starts as the teleport is asked for, and display gives it up after the same time as any other: 15 s, and 3 s per attachment. An answer that comes later is handled as one with no teleport under way, which still moves the agent. No test covers this, since newview has no harness for the teleport state. To check it in world, add TeleportLocal under messageBans/trusted in app_settings/message.xml and teleport within the region: "Giving up on a teleport inside the region" is logged after 15 s, and the next teleport is accepted. Co-Authored-By: Claude Opus 5.5 --- indra/newview/llagent.cpp | 1 + indra/newview/llviewerdisplay.cpp | 34 ++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/indra/newview/llagent.cpp b/indra/newview/llagent.cpp index a4f9c7fc78..708b305ced 100644 --- a/indra/newview/llagent.cpp +++ b/indra/newview/llagent.cpp @@ -4218,6 +4218,7 @@ bool LLAgent::teleportCore(bool is_local) { LL_INFOS("Teleport") << "Setting teleport state to TELEPORT_LOCAL" << LL_ENDL; gAgent.setTeleportState( LLAgent::TELEPORT_LOCAL ); + gTeleportDisplayTimer.reset(); } else { diff --git a/indra/newview/llviewerdisplay.cpp b/indra/newview/llviewerdisplay.cpp index 1212534371..2995529a50 100644 --- a/indra/newview/llviewerdisplay.cpp +++ b/indra/newview/llviewerdisplay.cpp @@ -295,17 +295,39 @@ void display_stats() } } -static void update_tp_display(bool minimized) +// How long a teleport may go unfinished before the viewer gives up on it. +static F32 teleport_expiry_time() { - static LLCachedControl teleport_arrival_delay(gSavedSettings, "TeleportArrivalDelay"); - static LLCachedControl teleport_local_delay(gSavedSettings, "TeleportLocalDelay"); - S32 attach_count = 0; if (isAgentAvatarValid()) { attach_count = gAgentAvatarp->getAttachmentCount(); } - F32 teleport_save_time = TELEPORT_EXPIRY + TELEPORT_EXPIRY_PER_ATTACHMENT * attach_count; + return TELEPORT_EXPIRY + TELEPORT_EXPIRY_PER_ATTACHMENT * attach_count; +} + +// A teleport inside the region shows no display until its TeleportLocal comes, +// so update_tp_display doesn't run for it until then. Gives up on one whose +// answer never came, since otherwise every later teleport is refused as one +// made while teleporting. +static void give_up_on_unanswered_local_teleport() +{ + if (!gTeleportDisplay + && gAgent.getTeleportState() == LLAgent::TELEPORT_LOCAL + && gTeleportDisplayTimer.getElapsedTimeF32() > teleport_expiry_time()) + { + LL_WARNS("Teleport") << "Giving up on a teleport inside the region after " + << gTeleportDisplayTimer.getElapsedTimeF32() << " seconds with no answer" << LL_ENDL; + gAgent.setTeleportState(LLAgent::TELEPORT_NONE); + } +} + +static void update_tp_display(bool minimized) +{ + static LLCachedControl teleport_arrival_delay(gSavedSettings, "TeleportArrivalDelay"); + static LLCachedControl teleport_local_delay(gSavedSettings, "TeleportLocalDelay"); + + F32 teleport_save_time = teleport_expiry_time(); F32 teleport_elapsed = gTeleportDisplayTimer.getElapsedTimeF32(); F32 teleport_percent = teleport_elapsed * (100.f / teleport_save_time); if (gAgent.getTeleportState() != LLAgent::TELEPORT_START && teleport_percent > 100.f) @@ -526,6 +548,8 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) } gPipeline.applyRenderInputs(); + give_up_on_unanswered_local_teleport(); + gSnapshot = for_snapshot; if (LLPipeline::sRenderDeferred)