Bound JSON-RPC input by size and nesting depth

Enforce the nesting bound in one parse, capping allocation by depth, not input size.
This commit is contained in:
xirvik
2026-09-19 02:11:20 +00:00
committed by rakshasa
parent 6c6d2b9333
commit d3f4d18164
5 changed files with 130 additions and 1 deletions
+39 -1
View File
@@ -2,8 +2,10 @@
#include "rpc/jsonrpc.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <torrent/common.h>
#include <torrent/torrent.h>
#include <torrent/utils/string_manip.h>
@@ -223,13 +225,49 @@ handle_notification(const json& request) noexcept {
}
}
namespace {
using json_input_adapter = decltype(nlohmann::detail::input_adapter(std::declval<const char*>(), std::declval<const char*>()));
using json_dom_parser = nlohmann::detail::json_sax_dom_parser<json, json_input_adapter>;
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")) {
+7
View File
@@ -5,6 +5,8 @@
#include <cstdint>
#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
+1
View File
@@ -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
+79
View File
@@ -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<std::tuple<std::string, std::string, std::string>> 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);
}
+4
View File
@@ -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<TestMainThread> m_test_main_thread;