From 439d23ce6247c6a33ea90af65c6d24c597daade7 Mon Sep 17 00:00:00 2001 From: xirvik Date: Sat, 19 Sep 2026 02:11:20 +0000 Subject: [PATCH] Bound JSON-RPC input by size and nesting depth Enforce the nesting bound in one parse, capping allocation by depth, not input size. --- src/rpc/jsonrpc.cc | 40 +++++++++++++++++++- src/rpc/jsonrpc.h | 7 ++++ src/rpc/rpc_manager.cc | 1 + test/rpc/test_jsonrpc.cc | 79 ++++++++++++++++++++++++++++++++++++++++ test/rpc/test_jsonrpc.h | 4 ++ 5 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/rpc/jsonrpc.cc b/src/rpc/jsonrpc.cc index 8f41ee1c..ff14d52b 100644 --- a/src/rpc/jsonrpc.cc +++ b/src/rpc/jsonrpc.cc @@ -2,8 +2,10 @@ #include "rpc/jsonrpc.h" +#include #include #include +#include #include #include #include @@ -223,13 +225,49 @@ handle_notification(const json& request) noexcept { } } +namespace { + +using json_input_adapter = decltype(nlohmann::detail::input_adapter(std::declval(), std::declval())); +using json_dom_parser = nlohmann::detail::json_sax_dom_parser; + +class json_depth_limited_parser : public json_dom_parser { +public: + explicit json_depth_limited_parser(json& root) : json_dom_parser(root) {} + + bool start_object(std::size_t length) { return enter() && json_dom_parser::start_object(length); } + bool start_array(std::size_t length) { return enter() && json_dom_parser::start_array(length); } + + bool end_object() { m_depth--; return json_dom_parser::end_object(); } + bool end_array() { m_depth--; return json_dom_parser::end_array(); } + +private: + bool enter() { return ++m_depth <= max_json_depth; } + + uint32_t m_depth{0}; +}; + +} // namespace + bool JsonRpc::process(const char* in_buffer, uint32_t length, slot_write callback) { json response; json body; + if (length > m_size_limit) { + auto err_str = json_error(JSONRPC_INVALID_REQUEST_ERROR, "content size exceeds maximum RPC limit", nullptr).dump(); + + return callback(err_str.c_str(), err_str.size()); + } + try { - body = json::parse(in_buffer, in_buffer + length); + json_depth_limited_parser handler(body); + + if (!json::sax_parse(in_buffer, in_buffer + length, &handler)) { + auto err_str = json_error(JSONRPC_INVALID_REQUEST_ERROR, "maximum nesting depth exceeded", nullptr).dump(); + + return callback(err_str.c_str(), err_str.size()); + } + switch (body.type()) { case json::value_t::object: { if (!body.contains("id")) { diff --git a/src/rpc/jsonrpc.h b/src/rpc/jsonrpc.h index 873fc30a..c1a9ce03 100644 --- a/src/rpc/jsonrpc.h +++ b/src/rpc/jsonrpc.h @@ -5,6 +5,8 @@ #include +#include "rpc/scgi_task.h" + namespace rpc { class JsonRpc { @@ -17,6 +19,11 @@ public: bool process(const char* in_buffer, uint32_t length, slot_write callback); void insert_command(const char* name, const char* parm, const char* doc) {}; + + void set_size_limit(uint64_t size) { m_size_limit = size; } + +private: + uint64_t m_size_limit{SCgiTask::max_content_size}; }; } // namespace rpc diff --git a/src/rpc/rpc_manager.cc b/src/rpc/rpc_manager.cc index 7778f4d1..c94343dc 100644 --- a/src/rpc/rpc_manager.cc +++ b/src/rpc/rpc_manager.cc @@ -176,6 +176,7 @@ RpcManager::set_size_limit(uint64_t size) { throw torrent::input_error("XMLRPC size limit is too small to hold a request."); m_xmlrpc.set_size_limit(size); + m_jsonrpc.set_size_limit(size); } void diff --git a/test/rpc/test_jsonrpc.cc b/test/rpc/test_jsonrpc.cc index c5bec92c..77627dab 100644 --- a/test/rpc/test_jsonrpc.cc +++ b/test/rpc/test_jsonrpc.cc @@ -173,3 +173,82 @@ TestJsonrpc::test_response_size_limit() { output.size() <= rpc::SCgiTask::max_response_size); CPPUNIT_ASSERT_EQUAL(expected, output); } + +// The bound is applied while the document is parsed: json_to_object only ever +// walks "params", and by the time it runs the whole tree already exists. +void +TestJsonrpc::test_depth_limit() { + // A JSON string holding a quote and brackets that must not be counted. + const std::string tricky = R"("\"[[[")"; + + std::vector> requests = { + std::make_tuple("Nesting under the limit is accepted", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + + std::string(1000, '[') + std::string(1000, ']') + R"(], "id": 1})", + R"({"id":1,"jsonrpc":"2.0","result":[)" + + std::string(1000, '[') + std::string(1000, ']') + R"(]})"), + + // Nesting outside "params" is never converted, so json_to_object's own + // bound never sees it. + std::make_tuple("Nesting outside params is rejected", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": [""], "id": 1, "x": )" + + std::string(2000, '[') + std::string(2000, ']') + R"(})", + R"({"error":{"code":-32600,"message":"maximum nesting depth exceeded"},"id":null,"jsonrpc":"2.0"})"), + + std::make_tuple("Nesting over the limit is rejected", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + + std::string(2000, '[') + std::string(2000, ']') + R"(], "id": 1})", + R"({"error":{"code":-32600,"message":"maximum nesting depth exceeded"},"id":null,"jsonrpc":"2.0"})"), + + std::make_tuple("Brackets inside a string are not nesting", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", ")" + + std::string(2000, '[') + R"("], "id": 1})", + R"({"id":1,"jsonrpc":"2.0","result":[")" + + std::string(2000, '[') + R"("]})"), + + std::make_tuple("An escaped quote does not end a string", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + tricky + R"(, ")" + + std::string(2000, '[') + R"("], "id": 1})", + R"({"id":1,"jsonrpc":"2.0","result":[)" + tricky + R"(,")" + + std::string(2000, '[') + R"("]})"), + + // The bound is on the whole document, so the outer object and the params + // array are two of the 1024 containers and 1022 are left for the payload. + std::make_tuple("Nesting one below the limit is accepted", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + + std::string(1021, '[') + std::string(1021, ']') + R"(], "id": 1})", + R"({"id":1,"jsonrpc":"2.0","result":[)" + + std::string(1021, '[') + std::string(1021, ']') + R"(]})"), + + std::make_tuple("Nesting at the limit is accepted", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + + std::string(1022, '[') + std::string(1022, ']') + R"(], "id": 1})", + R"({"id":1,"jsonrpc":"2.0","result":[)" + + std::string(1022, '[') + std::string(1022, ']') + R"(]})"), + + std::make_tuple("Nesting one over the limit is rejected", + R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": ["", )" + + std::string(1023, '[') + std::string(1023, ']') + R"(], "id": 1})", + R"({"error":{"code":-32600,"message":"maximum nesting depth exceeded"},"id":null,"jsonrpc":"2.0"})"), + }; + + for (auto& test : requests) { + std::string output; + m_jsonrpc.process(std::get<1>(test).c_str(), std::get<1>(test).size(), [&output](const char* c, uint32_t l) { output.append(c, l); return true; }); + CPPUNIT_ASSERT_EQUAL_MESSAGE(std::get<0>(test), std::get<2>(test), output); + } +} + +// network.xmlrpc.size_limit is the only knob bounding how much input a single +// request may spend memory on, and it has to bound the JSON path too. +void +TestJsonrpc::test_size_limit() { + const std::string request = R"({"jsonrpc": "2.0", "method": "jsonrpc_reflect", "params": [""], "id": 1})"; + const std::string expected = R"({"error":{"code":-32600,"message":"content size exceeds maximum RPC limit"},"id":null,"jsonrpc":"2.0"})"; + + std::string output; + m_jsonrpc.set_size_limit(1); + m_jsonrpc.process(request.c_str(), request.size(), [&output](const char* c, uint32_t l) { output.append(c, l); return true; }); + + CPPUNIT_ASSERT_EQUAL(expected, output); +} diff --git a/test/rpc/test_jsonrpc.h b/test/rpc/test_jsonrpc.h index 5e2638c5..cc793412 100644 --- a/test/rpc/test_jsonrpc.h +++ b/test/rpc/test_jsonrpc.h @@ -9,6 +9,8 @@ class TestJsonrpc : public test_fixture { CPPUNIT_TEST(test_basics); CPPUNIT_TEST(test_response_size_limit); + CPPUNIT_TEST(test_depth_limit); + CPPUNIT_TEST(test_size_limit); CPPUNIT_TEST_SUITE_END(); @@ -18,6 +20,8 @@ public: void test_basics(); void test_response_size_limit(); + void test_depth_limit(); + void test_size_limit(); private: std::unique_ptr m_test_main_thread;