mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 07:26:51 +00:00
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:
176
src/test/rpc/RPCHandler_test.cpp
Normal file
176
src/test/rpc/RPCHandler_test.cpp
Normal 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
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user