Skip to content

fix: Validate the tx_history start parameter - #7891

Draft
SoubeDev wants to merge 2 commits into
XRPLF:developfrom
SoubeDev:soubedev/fix-6769-tx-history-start-validation
Draft

fix: Validate the tx_history start parameter#7891
SoubeDev wants to merge 2 commits into
XRPLF:developfrom
SoubeDev:soubedev/fix-6769-tx-history-start-validation

Conversation

@SoubeDev

@SoubeDev SoubeDev commented Jul 28, 2026

Copy link
Copy Markdown

High Level Overview of Change

tx_history returned a generic internal error when its start parameter was
malformed. This adds a type guard so the handler returns invalidParams instead,
matching how every other RPC handler treats an unsigned-integer field.

Fixes #6769.

Context of Change

doTxHistory read its only parameter with an unguarded conversion:

unsigned int const startIndex = context.params[jss::start].asUInt();

json::Value::asUInt() throws for several values a client can send
(src/libxrpl/json/json_value.cpp:630-671):

start asUInt() behavior
-1 (negative Int) Throw<json::Error> — "Negative integer can not be converted..."
1e30 (Real out of range) Throw<json::Error> — "Real out of unsigned integer range"
{} / [] Throw<json::Error> — "Type is not convertible to uint"
"abc" beast::lexicalCastThrowBadLexicalCast

The throw escaped into callMethod's blanket catch (std::exception&)
(src/xrpld/rpc/detail/RPCHandler.cpp:177-187), which injects RpcInternal. So bad
user input was reported as a server fault: the client got internal (code 73) rather
than invalidParams (code 31), and the request was recorded via perfLog.rpcError as
though the server had failed.

A second, quieter group of values never threw at all — "5", null, true and
1.5 were silently coerced to a number and the request proceeded.

This is long-standing rather than a recent regression; the unguarded conversion is
present as far back as the handler's history goes, predating the repo-wide
clang-format pass in 50760c6 (2020-04-17).

The fix reuses the predicate readLimitField already applies to limit
(src/xrpld/rpc/detail/RPCHelpers.cpp:126-127), so the two are consistent:

auto const& jvStart = context.params[jss::start];
if (!jvStart.isUInt() && (!jvStart.isInt() || jvStart.asInt() < 0))
    return rpcError(RpcInvalidParams);

unsigned int const startIndex = jvStart.asUInt();

The guard is placed before the existing startIndex > 10000 role check so a malformed
value can never reach it.

API Impact

  • Public API: Breaking change (in general, breaking changes should only impact the next api_version)
  • Public API: New feature (new methods and/or new fields)
  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)
  • Peer protocol change (must be backward compatible or bump the peer protocol version)

I have checked "Breaking change" deliberately, and want to flag the nuance for
reviewers rather than bury it.

Making the throwing cases return invalidParams instead of internal is not
breaking — those requests already failed.

What is breaking is that start values which were previously coerced are now
rejected: "5" (a numeric string), null, true/false, and non-whole reals like
1.5. A client relying on any of those gets invalidParams where it used to get a
result.

The usual remedy — gating the stricter behavior behind the next api_version — is not
available here. tx_history is registered minApiVer = 1, maxApiVer = 1
(src/xrpld/rpc/detail/Handler.cpp:323-328) and is listed under "Removed methods" in
API-VERSION-2.md, so there is no later version to gate against. The options were to
match readLimitField's strictness or to keep accepting numeric strings indefinitely;
I chose consistency with the rest of the RPC layer, on the grounds that the affected
surface is a single v1-only command. Happy to switch to a lenient variant that still
accepts numeric strings if reviewers prefer.

API-CHANGELOG.md has been updated under UnreleasedBugfixes.

Before / After

Request:

{ "command": "tx_history", "start": -1 }

Before:

{
  "error": "internal",
  "error_code": 73,
  "error_message": "Internal error.",
  "status": "error"
}

After:

{
  "error": "invalidParams",
  "error_code": 31,
  "error_message": "Invalid parameters.",
  "status": "error"
}

Valid input is unaffected: start as a non-negative integer behaves exactly as
before, including the existing noPermission response for non-admin callers above
10000.

Test Plan

testBadInput() in src/test/rpc/TransactionHistory_test.cpp gains a table-driven
block covering nine malformed values — -1, "abc", "5", 1.5, 1e30, true,
null, an object, and an array — each asserting invalidParams / status == "error".

To confirm the tests actually exercise the change rather than passing vacuously, I
reverted the handler, rebuilt, and re-ran: 13 assertions fail without the fix and 0
with it.
The count decomposes exactly as the two old behaviors predict — the five
throwing inputs previously returned internal, failing the error assertion but
passing status == "error" (1 each), while the four silently-coerced inputs
previously succeeded, failing both (2 each).

Results on macOS (arm64, apple-clang 17, Debug):

./xrpld --unittest=TransactionHistory
  3 cases, 507 tests total, 0 failures

./xrpld --unittest=RPCCall
  3 cases, 2085 tests total, 0 failures

RPCCall is included because it covers the command-line parser, which this change
deliberately does not touch — see Future Tasks.

Future Tasks

RPCParser::parseTxHistory (src/xrpld/rpc/detail/RPCCall.cpp:1132) has the same
unguarded asUInt() on the command-line path, so xrpld tx_history abc still throws
and surfaces as internal via RPCCall.cpp:1804-1809. Three cases in
RPCCall_test.cpp:5477-5500 already assert this, each marked
// Note: this really shouldn't throw, but does at the moment. I left it out to keep
this PR scoped to the reported issue; it is a small follow-up if reviewers would like
it addressed.

SoubeDev added 2 commits July 28, 2026 17:05
doTxHistory read the start parameter with an unguarded asUInt(), which
throws for negative, out-of-range, non-numeric and non-scalar values.
The throw was swallowed by callMethod's catch-all, so a malformed start
produced a generic internal error rather than invalidParams, and the
request was recorded as a server fault in the perf log.

Guard the conversion with the same isUInt()/isInt() check that
readLimitField already applies to the limit field, and return
invalidParams when it fails.

This makes the handler stricter: a start given as a numeric string,
null, or a bool was previously coerced and is now rejected. tx_history
is registered for API version 1 only and is retired in version 2, so
the affected surface is small.

Fixes XRPLF#6769
The entry was added with a placeholder because the pull request did not
exist when the fix was committed. Point it at XRPLF#7891.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TxHistory handler calls asUInt without validation on start parameter

1 participant