fix(rpc): Do not let span naming change the RPC error a client sees

resolveCommandSpanName() converted command/method to a string with no type
check. json::Value::asString() throws for an array or an object, so a request
whose nested method is [] reached that conversion and the throw replaced a
clean tooBusy reply with internal.

The overloaded path is the only way in: fillHandler() returns tooBusy before
anything has read those fields, and every other exit either converted them
itself or means neither field is present. The effect is that the error code a
client receives depends on whether telemetry was compiled in, which telemetry
must never do.

The span now falls back to its existing unknown-command label when either
present field is not a string. The WebSocket path already validates both
fields before dispatch, so it is left alone.

The test drives a genuinely overloaded job queue, reading the threshold from
the production constant rather than copying it, and asserts the client still
gets tooBusy. It lives in the Beast tree because doCommand is daemon code and
needs jtx, which the gtest binary cannot reach.
This commit is contained in:
Pratik Mankawde
2026-09-22 19:06:07 +01:00
parent 3b2192a48b
commit c587cf5edf
2 changed files with 195 additions and 9 deletions

View File

@@ -0,0 +1,176 @@
#include <test/jtx/Env.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/RPCHandler.h>
#include <xrpld/rpc/Role.h>
#include <xrpld/rpc/Status.h>
#include <xrpld/rpc/detail/Tuning.h>
#include <xrpl/basics/scope.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/core/Job.h>
#include <xrpl/core/JobQueue.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/ApiVersion.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/resource/Charge.h>
#include <xrpl/resource/Consumer.h>
#include <xrpl/resource/Fees.h>
#include <exception>
#include <future>
#include <string>
namespace xrpl::test {
/**
* Checks the error a busy server reports for a request it never dispatches.
*
* RPCHandler_test ──doCommand()──> RPC::fillHandler()
* │ │
* └── fills ──> JobQueue <── reads ─┘
*
* An overloaded server answers rpcTOO_BUSY before it reads the command name, so
* the request fields still hold whatever json type the client sent. Anything
* that runs afterwards to describe the error has to cope with that and leave
* the answer alone.
*
* @note Each testcase keeps one job-queue worker blocked for as long as it
* runs, and releases it before returning.
*/
class RPCHandler_test : public beast::unit_test::Suite
{
/**
* How many jobs to queue to hold the server over its overload threshold.
* One job is dispatched straight away, so one spare keeps the waiting
* count above the limit.
*/
static constexpr int kOverloadJobs = rpc::tuning::kMaxJobQueueClients + 2;
/**
* Dispatches one request on an overloaded server and checks the client is
* told the server is busy.
*
* @param params Request fields, in the form fillHandler() reads them.
*/
void
expectTooBusy(json::Value const& params)
{
using namespace jtx;
Env env{*this};
auto& app = env.app();
// Only one job of this type runs at a time, so every job after the
// first stays queued until the gate opens. They also sort above
// JtClient, the priority the overload check counts from.
std::promise<void> gate;
std::shared_future<void> const open = gate.get_future().share();
ScopeExit const openGate{[&gate]() { gate.set_value(); }};
int queued = 0;
for (int i = 0; i < kOverloadJobs; ++i)
{
if (app.getJobQueue().addJob(JtSweep, "overload", [open]() { open.wait(); }))
++queued;
}
BEAST_EXPECT(queued == kOverloadJobs);
BEAST_EXPECT(app.getJobQueue().getJobCountGE(JtClient) > rpc::tuning::kMaxJobQueueClients);
resource::Charge loadType = resource::kFeeReferenceRpc;
resource::Consumer consumer;
rpc::JsonContext context{
{.j = env.journal,
.app = app,
.loadType = loadType,
.netOps = app.getOPs(),
.ledgerMaster = app.getLedgerMaster(),
.consumer = consumer,
.role = Role::USER,
.coro = {},
.infoSub = {},
.apiVersion = rpc::kApiVersionIfUnspecified},
params,
{}};
json::Value result;
rpc::Status status;
std::string thrown;
try
{
status = rpc::doCommand(context, result);
}
catch (std::exception const& e)
{
thrown = e.what();
}
if (BEAST_EXPECTS(thrown.empty(), "doCommand threw: " + thrown))
{
BEAST_EXPECT(status.type() == rpc::Status::Type::ErrorCodeI);
BEAST_EXPECT(status.toErrorCode() == RpcTooBusy);
BEAST_EXPECT(result[jss::error].asString() == "tooBusy");
BEAST_EXPECT(result[jss::error_code].asInt() == static_cast<int>(RpcTooBusy));
}
}
/**
* Checks a well-formed request on an overloaded server. This is the control
* for the two cases below: it shares their fixture and their assertions,
* and differs only in that every field it sends is a string.
*/
void
testRegisteredCommand()
{
testcase("Busy server, registered command");
json::Value params = json::ValueType::Object;
params[jss::command] = "ping";
expectTooBusy(params);
}
/**
* Checks a request whose "method" field is not a string.
*/
void
testNonStringMethod()
{
testcase("Busy server, method field is not a string");
// The HTTP path sets "command" from the outer method name it has
// already checked, and passes the inner request object through
// untouched, so "method" can arrive holding any json type.
json::Value params = json::ValueType::Object;
params[jss::command] = "ping";
params[jss::method] = json::ValueType::Array;
expectTooBusy(params);
}
/**
* Checks a request whose "command" field is not a string.
*/
void
testNonStringCommand()
{
testcase("Busy server, command field is not a string");
json::Value params = json::ValueType::Object;
params[jss::command] = json::ValueType::Object;
expectTooBusy(params);
}
public:
void
run() override
{
testRegisteredCommand();
testNonStringMethod();
testNonStringCommand();
}
};
BEAST_DEFINE_TESTSUITE(RPCHandler, rpc, xrpl);
} // namespace xrpl::test

View File

@@ -235,30 +235,40 @@ callMethod(JsonContext& context, Handler::Method method, std::string_view name,
// Resolve the span suffix / command attribute for a request that failed in
// fillHandler. Returns the canonical handler name for a recognized command
// (a finite, bounded set) or the literal "unknown" for a request that omits
// both fields or names an unregistered command. The raw request value is
// deliberately NOT used: the command attribute is promoted to a Prometheus
// label by the spanmetrics connector, so an attacker-controlled string would
// let arbitrary request input drive unbounded span-name / label cardinality.
// both fields, supplies one that is not a string, or names an unregistered
// command. The raw request value is deliberately NOT used: the command
// attribute is promoted to a Prometheus label by the spanmetrics connector, so
// an attacker-controlled string would let arbitrary request input drive
// unbounded span-name / label cardinality.
// Resolving against the registry keeps per-command error attribution for real
// commands (e.g. a submit rejected with rpcTOO_BUSY stays rpc.command.submit)
// while collapsing garbage input to a single series.
std::string_view
resolveCommandSpanName(JsonContext const& context)
{
if (!context.params.isMember(jss::command) && !context.params.isMember(jss::method))
bool const hasCommand = context.params.isMember(jss::command);
bool const hasMethod = context.params.isMember(jss::method);
if (!hasCommand && !hasMethod)
return rpc_span::val::unknownCommand;
// A json array or object throws when asked for its string value, and no
// non-string field names a handler. The reply's error code is already
// decided, so naming the span must not be able to change it.
if ((hasCommand && !context.params[jss::command].isString()) ||
(hasMethod && !context.params[jss::method].isString()))
return rpc_span::val::unknownCommand;
// fillHandler() rejects a request that supplies both fields with differing
// values as rpcUNKNOWN_COMMAND. Mirror that here, or the span would be
// labelled with one of the two names and misattribute the error to a
// command that was never dispatched.
if (context.params.isMember(jss::command) && context.params.isMember(jss::method) &&
if (hasCommand && hasMethod &&
context.params[jss::command].asString() != context.params[jss::method].asString())
return rpc_span::val::unknownCommand;
std::string const cmd = context.params.isMember(jss::command)
? context.params[jss::command].asString()
: context.params[jss::method].asString();
std::string const cmd = hasCommand ? context.params[jss::command].asString()
: context.params[jss::method].asString();
auto const* handler = getHandler(context.apiVersion, context.app.config().betaRpcApi, cmd);
return (handler != nullptr) ? std::string_view{handler->name}