From 781520fef8e9c21c819f6eca7fab4e7645621c34 Mon Sep 17 00:00:00 2001 From: xirvik Date: Mon, 10 Aug 2026 02:13:54 +0000 Subject: [PATCH 01/10] Close the connection when an SCGI header does not fit the buffer. A malformed header aborted the process from the SCGI thread. --- src/rpc/scgi_task.cc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rpc/scgi_task.cc b/src/rpc/scgi_task.cc index f9a05882..647851b7 100644 --- a/src/rpc/scgi_task.cc +++ b/src/rpc/scgi_task.cc @@ -94,8 +94,12 @@ SCgiTask::event_read() { if (m_content_length == 0) read_length--; - if (read_length <= 0) + if (read_length <= 0) { + if (m_content_length == 0) + return close(); + throw torrent::internal_error("SCgiTask::event_read() no space in buffer for event_read."); + } int bytes = ::recv(file_descriptor(), m_buffer.data() + m_position, read_length, 0); From a075001a7361cbd36a134969e50525ba7b490bf2 Mon Sep 17 00:00:00 2001 From: xirvik Date: Mon, 10 Aug 2026 02:22:50 +0000 Subject: [PATCH 02/10] Catch every json exception when processing a JSONRPC request. A number overflow escaped the two handlers and killed the process. --- src/rpc/jsonrpc.cc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/rpc/jsonrpc.cc b/src/rpc/jsonrpc.cc index 294e292c..77e68479 100644 --- a/src/rpc/jsonrpc.cc +++ b/src/rpc/jsonrpc.cc @@ -258,11 +258,8 @@ JsonRpc::process(const char* in_buffer, uint32_t length, slot_write callback) { return callback(response_str.c_str(), response_str.size()); - } catch (json::parse_error& e) { - auto err_str = json_error(JSONRPC_PARSE_ERROR, e.what(), nullptr).dump(-1, ' ', false, json::error_handler_t::replace); - return callback(err_str.c_str(), err_str.size()); - } catch (json::type_error& e) { - // Type errors may be caused by invalid UTF-8 strings in exception strings, hence the ::replace + } catch (json::exception& e) { + // Exception strings may contain invalid UTF-8, hence the ::replace auto err_str = json_error(JSONRPC_PARSE_ERROR, e.what(), nullptr).dump(-1, ' ', false, json::error_handler_t::replace); return callback(err_str.c_str(), err_str.size()); } From 946a4b5c831c05dff6da3d984550832086458892 Mon Sep 17 00:00:00 2001 From: xirvik Date: Mon, 10 Aug 2026 02:41:08 +0000 Subject: [PATCH 03/10] Check the focus iterator before dereferencing it on erase. Erasing a download read past the end of the view. --- src/ui/download_list.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/download_list.cc b/src/ui/download_list.cc index 1ab1eab7..c9aca0ff 100644 --- a/src/ui/download_list.cc +++ b/src/ui/download_list.cc @@ -89,7 +89,7 @@ DownloadList::unfocus_download(core::Download* d) { if (m_state == DISPLAY_DOWNLOAD && d == static_cast(m_uiArray[DISPLAY_DOWNLOAD])->download()) activate_display(DISPLAY_DOWNLOAD_LIST); - if (*current_view()->focus() == d && current_view()->focus() < current_view()->end_visible()) + if (current_view()->focus() < current_view()->end_visible() && *current_view()->focus() == d) current_view()->next_focus(); } From b40106824655081bce146448f991679afff46a42 Mon Sep 17 00:00:00 2001 From: xirvik Date: Mon, 10 Aug 2026 02:51:32 +0000 Subject: [PATCH 04/10] Let the choke group list own its groups. Each choke_group.insert leaked a group, and size reported a stale copy. --- src/command_groups.cc | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/command_groups.cc b/src/command_groups.cc index bc67ab16..0b52905b 100644 --- a/src/command_groups.cc +++ b/src/command_groups.cc @@ -1,5 +1,7 @@ #include "config.h" +#include + #include #include #include @@ -111,7 +113,7 @@ apply_cg_all_update_balance(bool is_up) { // #else -std::vector cg_list_hack; +std::vector> cg_list_hack; int64_t cg_get_index(const torrent::Object& raw_args) { @@ -121,7 +123,7 @@ cg_get_index(const torrent::Object& raw_args) { if (arg.is_string()) { if (!rpc::parse_whole_value_nothrow(arg.as_string().c_str(), &index)) { - auto itr = std::find_if(cg_list_hack.begin(), cg_list_hack.end(), [&arg](torrent::choke_group* cg) { return arg.as_string() == cg->name(); }); + auto itr = std::find_if(cg_list_hack.begin(), cg_list_hack.end(), [&arg](const auto& cg) { return arg.as_string() == cg->name(); }); if (itr == cg_list_hack.end()) throw torrent::input_error("Choke group not found."); @@ -149,7 +151,7 @@ cg_get_group(const torrent::Object& raw_args) { if ((size_t)index >= cg_list_hack.size()) throw torrent::input_error("Choke group not found."); - return cg_list_hack.at(index); + return cg_list_hack.at(index).get(); } int64_t cg_d_group(core::Download* download) { return download->group(); } @@ -162,7 +164,7 @@ torrent::Object apply_cg_list() { torrent::Object::list_type result; - for (auto itr : cg_list_hack) + for (const auto& itr : cg_list_hack) result.push_back(itr->name()); return torrent::Object::from_list(result); @@ -180,10 +182,10 @@ apply_cg_insert(const std::string& arg) { if (rpc::parse_whole_value_nothrow(arg.c_str(), &dummy)) throw torrent::input_error("Cannot use a value string as choke group name."); - if (arg.empty() || std::any_of(cg_list_hack.begin(), cg_list_hack.end(), [&arg](auto cg) { return arg == cg->name(); })) + if (arg.empty() || std::any_of(cg_list_hack.begin(), cg_list_hack.end(), [&arg](const auto& cg) { return arg == cg->name(); })) throw torrent::input_error("Duplicate name for choke group."); - cg_list_hack.push_back(new torrent::choke_group()); + cg_list_hack.push_back(std::make_unique()); cg_list_hack.back()->set_name(arg); cg_list_hack.back()->up_queue()->set_heuristics(torrent::HEURISTICS_UPLOAD_LEECH); @@ -194,7 +196,7 @@ apply_cg_insert(const std::string& arg) { torrent::Object apply_cg_index_of(const std::string& arg) { - auto itr = std::find_if(cg_list_hack.begin(), cg_list_hack.end(), [&arg](torrent::choke_group* cg) { return arg == cg->name(); }); + auto itr = std::find_if(cg_list_hack.begin(), cg_list_hack.end(), [&arg](const auto& cg) { return arg == cg->name(); }); if (itr == cg_list_hack.end()) throw torrent::input_error("Choke group not found."); @@ -206,7 +208,7 @@ torrent::Object apply_cg_all_update_balance(bool is_up) { LT_LOG_SUBSYSTEM("apply update balance: hack is_up:%i", (int)is_up); - for (auto itr : cg_list_hack) { + for (const auto& itr : cg_list_hack) { if (is_up) itr->up_queue()->balance(); else @@ -346,7 +348,7 @@ initialize_command_groups() { #else apply_cg_insert("default"); - CMD_ANY ("choke_group.size", std::bind(&std::vector::size, cg_list_hack)); + CMD_ANY ("choke_group.size", [](auto, auto) { return (int64_t)cg_list_hack.size(); }); CMD_ANY_STRING ("choke_group.index_of", std::bind(&apply_cg_index_of, std::placeholders::_2)); #endif From 68904ba00534ada11e5ed572f6dfa75c80c340e7 Mon Sep 17 00:00:00 2001 From: xirvik Date: Mon, 10 Aug 2026 06:03:44 +0000 Subject: [PATCH 05/10] Limit the padding string.rpad and string.lpad will build. An untrusted caller could exhaust memory or the response limit. --- src/command_string.cc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/command_string.cc b/src/command_string.cc index 0a7d7062..30d38a0c 100644 --- a/src/command_string.cc +++ b/src/command_string.cc @@ -19,6 +19,8 @@ namespace { +constexpr int64_t max_string_pad_size = 1 << 20; + const std::string whitespace_characters = " \t\n\r\f\v"; // The byte offset of every utf-8 character in 'text', terminated by the offset @@ -249,10 +251,14 @@ apply_string_pad(const char* name, const torrent::Object::list_type& args, bool if (pad_size <= text_length || padding.empty()) return text; + if (pad_size - text_length > max_string_pad_size) + throw torrent::input_error(std::string(name) + ": padding is too large."); + auto padding_offsets = utf8_offsets(padding); auto padding_length = static_cast(padding_offsets.size() - 1); std::string result; + result.reserve(pad_size - text_length); for (int64_t i = 0; i < pad_size - text_length; i++) { auto index = static_cast(i % padding_length); From 6fa139f7bf079ff57fab6f0011544924bc9ef506 Mon Sep 17 00:00:00 2001 From: Jari Sundell Date: Fri, 14 Aug 2026 19:27:04 +0900 Subject: [PATCH 06/10] Reduce max_string_pad_size from 1<<20 to 1<<14 --- src/command_string.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/command_string.cc b/src/command_string.cc index 30d38a0c..270101f7 100644 --- a/src/command_string.cc +++ b/src/command_string.cc @@ -19,7 +19,7 @@ namespace { -constexpr int64_t max_string_pad_size = 1 << 20; +constexpr int64_t max_string_pad_size = 1 << 14; const std::string whitespace_characters = " \t\n\r\f\v"; From b24db0eaa5f5ed7f55cb521f52d03d976673e158 Mon Sep 17 00:00:00 2001 From: xirvik Date: Wed, 12 Aug 2026 15:50:56 +0000 Subject: [PATCH 07/10] Do not dereference missing text nodes in the tinyxml2 backend. Empty elements and element children crashed the parser on a null. --- src/rpc/xmlrpc_tinyxml2.cc | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/rpc/xmlrpc_tinyxml2.cc b/src/rpc/xmlrpc_tinyxml2.cc index 7ef6098b..099f6fbd 100644 --- a/src/rpc/xmlrpc_tinyxml2.cc +++ b/src/rpc/xmlrpc_tinyxml2.cc @@ -49,13 +49,23 @@ element_access(const tinyxml2::XMLElement* elem, std::initializer_listToText(); + + if (text == nullptr) + throw rpc_error(XMLRPC_TYPE_ERROR, "expected a text value"); + + return text->Value(); +} + long long element_to_int(const tinyxml2::XMLNode* elem) { char* pos; if (elem->FirstChild() == nullptr) { throw rpc_error(XMLRPC_TYPE_ERROR, "unable to parse empty integer"); } - auto str = elem->FirstChild()->ToText()->Value(); + auto str = element_text_value(elem->FirstChild()); auto result = std::strtoll(str, &pos, 10); if (pos == str || *pos != '\0') throw rpc_error(XMLRPC_TYPE_ERROR, "unable to parse integer value"); @@ -85,7 +95,7 @@ xml_value_to_object(const tinyxml2::XMLNode* elem) { if (child_element == nullptr) return torrent::Object(""); - return torrent::Object(child_element->ToText()->Value()); + return torrent::Object(element_text_value(child_element)); } else if (std::strncmp(root_type, "int", sizeof("int")) == 0 || std::strncmp(root_type, "i4", sizeof("i4")) == 0 || @@ -98,7 +108,7 @@ xml_value_to_object(const tinyxml2::XMLNode* elem) { if (child_element == nullptr) throw rpc_error(XMLRPC_TYPE_ERROR, "empty boolean element"); - auto boolean_text = std::string(child_element->ToText()->Value()); + auto boolean_text = std::string(element_text_value(child_element)); if (boolean_text == "1") return torrent::Object((int64_t)1); @@ -129,7 +139,12 @@ xml_value_to_object(const tinyxml2::XMLNode* elem) { if (name_element == nullptr) throw rpc_error(XMLRPC_PARSE_ERROR, "struct member missing name element"); - map[name_element->GetText()] = std::move(xml_value_to_object(child->FirstChildElement("value"))); + auto name_text = name_element->GetText(); + + if (name_text == nullptr) + throw rpc_error(XMLRPC_PARSE_ERROR, "struct member has an empty name element"); + + map[name_text] = std::move(xml_value_to_object(child->FirstChildElement("value"))); } return map_raw; @@ -140,7 +155,7 @@ xml_value_to_object(const tinyxml2::XMLNode* elem) { if (child_element == nullptr) return torrent::Object(""); - return torrent::Object(utils::decode_base64(utils::remove_newlines(child_element->ToText()->Value()))); + return torrent::Object(utils::decode_base64(utils::remove_newlines(element_text_value(child_element)))); } else { throw rpc_error(XMLRPC_INTERNAL_ERROR, "received unsupported value type: " + std::string(root_type)); @@ -300,6 +315,9 @@ process_document(const tinyxml2::XMLDocument* doc, tinyxml2::XMLPrinter* printer if (doc->FirstChildElement("methodCall")->FirstChildElement("methodName") == nullptr) throw rpc_error(XMLRPC_PARSE_ERROR, "methodName element not found"); auto method_name = doc->FirstChildElement("methodCall")->FirstChildElement("methodName")->GetText(); + + if (method_name == nullptr) + throw rpc_error(XMLRPC_PARSE_ERROR, "methodName element is empty"); torrent::Object result; // Add a shim here for system.multicall to allow better code reuse, and @@ -310,6 +328,9 @@ process_document(const tinyxml2::XMLDocument* doc, tinyxml2::XMLPrinter* printer auto parent_elements = element_access(doc->RootElement(), {"params", "param", "value", "array", "data"}); for (auto child = parent_elements->FirstChildElement("value"); child; child = child->NextSiblingElement("value")) { auto sub_method_name = element_access(child, {"struct", "member", "value", "string"})->GetText(); + + if (sub_method_name == nullptr) + throw rpc_error(XMLRPC_PARSE_ERROR, "multicall methodName element is empty"); // If sub_params ends up a nullptr at the end of this if-chian, // execute_command will turn it into an empty list auto sub_params = element_access(child, {"struct", "member"}); From 5e7dd98c671aa5ac2075ddd3090f146d7d6485c0 Mon Sep 17 00:00:00 2001 From: Jacob Reynolds Date: Wed, 12 Aug 2026 08:02:13 -0700 Subject: [PATCH 08/10] Update rtorrent.rc.lua-example to use new `network.listen.port.x` commands See #1935 --- doc/rtorrent.rc.lua-example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/rtorrent.rc.lua-example b/doc/rtorrent.rc.lua-example index be3d0ffd..08d37fec 100644 --- a/doc/rtorrent.rc.lua-example +++ b/doc/rtorrent.rc.lua-example @@ -53,8 +53,8 @@ rc.execute.throw( cfg.watch..'/start'})) -- Listening port for incoming peer traffic (fixed; you can also randomize it) -rc.network.port_range = '50000-50000' -rc.network.port_random = false +rc.network.listen.port.range = '50000-50000' +rc.network.listen.port.random = false -- Tracker-less torrent and UDP tracker support -- (conservative settings for 'private' trackers, change for 'public') From 9ff0cd28d5fdb8e6c95d7b04c29601dd42c99395 Mon Sep 17 00:00:00 2001 From: xirvik Date: Sat, 15 Aug 2026 22:38:24 +0000 Subject: [PATCH 09/10] Add schedule.if_absent for clients that re-register periodic tasks. schedule restarts a re-registered task's countdown, if_absent keeps the existing entry. --- src/command_events.cc | 8 +++- test/Makefile.am | 2 + test/rpc/test_command_scheduler.cc | 65 ++++++++++++++++++++++++++++++ test/rpc/test_command_scheduler.h | 17 ++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 test/rpc/test_command_scheduler.cc create mode 100644 test/rpc/test_command_scheduler.h diff --git a/src/command_events.cc b/src/command_events.cc index 8420cc5a..2afd600d 100644 --- a/src/command_events.cc +++ b/src/command_events.cc @@ -128,7 +128,7 @@ apply_remove_untied() { } torrent::Object -apply_schedule(const torrent::Object::list_type& args) { +apply_schedule(const torrent::Object::list_type& args, bool if_absent) { if (args.size() != 4) throw torrent::input_error("Wrong number of arguments."); @@ -138,6 +138,9 @@ apply_schedule(const torrent::Object::list_type& args) { auto& arg2 = (itr++)->as_string(); auto& arg3 = (itr++)->as_string(); + if (if_absent && control->command_scheduler()->find(arg1) != control->command_scheduler()->end()) + return torrent::Object(); + control->command_scheduler()->parse(arg1, arg2, arg3, *itr); return torrent::Object(); @@ -340,7 +343,8 @@ initialize_command_events() { CMD2_ANY ("close_untied", [](auto, auto) { return apply_close_untied(); }); CMD2_ANY ("remove_untied", [](auto, auto) { return apply_remove_untied(); }); - CMD2_ANY_LIST ("schedule", [](auto, auto& args) { return apply_schedule(args); }); + CMD2_ANY_LIST ("schedule", [](auto, auto& args) { return apply_schedule(args, false); }); + CMD2_ANY_LIST ("schedule.if_absent", [](auto, auto& args) { return apply_schedule(args, true); }); CMD2_ANY_STRING_V("schedule.remove", [](auto, auto& str) { return control->command_scheduler()->erase_str(str); }); CMD2_ANY_STRING_V("import", [](auto, auto& str) { return apply_import(str); }); diff --git a/test/Makefile.am b/test/Makefile.am index 599d1b3b..550051f8 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -35,6 +35,8 @@ rtorrent_Test_Rpc_SOURCES = $(rtorrent_Test_Common) \ rpc/test_command.h \ rpc/test_command_map.cc \ rpc/test_command_map.h \ + rpc/test_command_scheduler.cc \ + rpc/test_command_scheduler.h \ rpc/test_jsonrpc.cc \ rpc/test_jsonrpc.h \ rpc/test_xmlrpc.cc \ diff --git a/test/rpc/test_command_scheduler.cc b/test/rpc/test_command_scheduler.cc new file mode 100644 index 00000000..6ab4cb33 --- /dev/null +++ b/test/rpc/test_command_scheduler.cc @@ -0,0 +1,65 @@ +#include "config.h" + +#include "test/rpc/test_command_scheduler.h" + +#include + +#include "rpc/command_scheduler.h" +#include "rpc/command_scheduler_item.h" +#include "torrent/object.h" + +CPPUNIT_TEST_SUITE_REGISTRATION(TestCommandScheduler); + +namespace { + +const torrent::Object test_command = torrent::Object(std::string("print=scheduled")); + +std::chrono::microseconds +time_scheduled(rpc::CommandScheduler& scheduler, const std::string& key) { + auto itr = scheduler.find(key); + + CPPUNIT_ASSERT(itr != scheduler.end()); + + return (*itr)->time_scheduled(); +} + +} + +void +TestCommandScheduler::setUp() { + TestFixtureWithMainThread::setUp(); + + m_main_thread->test_set_cached_time(std::chrono::seconds(0)); +} + +void +TestCommandScheduler::tearDown() { + TestFixtureWithMainThread::tearDown(); +} + +void +TestCommandScheduler::test_parse_rearms_existing_key() { + rpc::CommandScheduler scheduler; + + scheduler.parse("key", "3600", "3600", test_command); + + auto first = time_scheduled(scheduler, "key"); + + m_main_thread->test_add_cached_time(std::chrono::seconds(600)); + scheduler.parse("key", "3600", "3600", test_command); + + CPPUNIT_ASSERT_EQUAL(size_t{1}, scheduler.size()); + CPPUNIT_ASSERT(time_scheduled(scheduler, "key") == first + std::chrono::seconds(600)); +} + +void +TestCommandScheduler::test_find_locates_a_scheduled_key() { + rpc::CommandScheduler scheduler; + + CPPUNIT_ASSERT(scheduler.find("key") == scheduler.end()); + + scheduler.parse("key", "3600", "3600", test_command); + + CPPUNIT_ASSERT(scheduler.find("key") != scheduler.end()); + CPPUNIT_ASSERT(scheduler.find("other") == scheduler.end()); +} diff --git a/test/rpc/test_command_scheduler.h b/test/rpc/test_command_scheduler.h new file mode 100644 index 00000000..c23cf938 --- /dev/null +++ b/test/rpc/test_command_scheduler.h @@ -0,0 +1,17 @@ +#include "test/helpers/test_main_thread.h" + +class TestCommandScheduler : public TestFixtureWithMainThread { + CPPUNIT_TEST_SUITE(TestCommandScheduler); + + CPPUNIT_TEST(test_parse_rearms_existing_key); + CPPUNIT_TEST(test_find_locates_a_scheduled_key); + + CPPUNIT_TEST_SUITE_END(); + +public: + void setUp(); + void tearDown(); + + void test_parse_rearms_existing_key(); + void test_find_locates_a_scheduled_key(); +}; From 58e627f2be269d0936b4761aeb65bd68ca90522d Mon Sep 17 00:00:00 2001 From: rakshasa Date: Sun, 16 Aug 2026 13:55:29 +0200 Subject: [PATCH 10/10] Fixed '-i ' command line argument. --- src/setup.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/setup.cc b/src/setup.cc index 7e9b6bc7..9aefaec6 100644 --- a/src/setup.cc +++ b/src/setup.cc @@ -37,7 +37,7 @@ parse_main_options(int argc, char** argv) { optionParser.insert_option('b', [](auto& arg) { rpc::call_command_set_string("network.bind_address.set", arg); }); optionParser.insert_option('d', [](auto& arg) { rpc::call_command_set_string("directory.default.set", arg); }); - optionParser.insert_option('i', [](auto& arg) { rpc::call_command_set_string("ip", arg); }); + optionParser.insert_option('i', [](auto& arg) { rpc::call_command_set_string("network.local_address.set", arg); }); optionParser.insert_option('p', [](auto& arg) { rpc::call_command_set_string("network.listen.port.range.set", arg); }); optionParser.insert_option('s', [](auto& arg) { rpc::call_command_set_string("session", arg); });