Skip to content

Commit 69573b2

Browse files
authored
Collection of bug-fixes (#993)
* fix #948: missing return in IOU amount underflow path (#948) When an IOU value's normalized mantissa falls below MIN_IOU_MANTISSA or its exponent falls below MIN_IOU_EXPONENT, the serializer computed the canonical zero bytes but dropped the result on the floor. Execution fell through to the general serialization path, producing a non-zero amount for a value that should round to zero (e.g. "1e-82" serialized to c0405af3107a4000 instead of 8000000000000000). Add the missing `return` so underflowing IOU amounts serialize to the canonical zero representation defined in the XRPL binary format spec (type bit set, sign/exponent/mantissa all zero). Includes a regression test covering 1e-82, 1e-96, and -1e-96. * fix: do not expose seed in Wallet init error message (#987) The XRPLAddressCodecException raised when a Wallet is constructed with an invalid seed embedded the raw seed in the message. Exception text is commonly logged or shipped to error-tracking systems, so this leaked secret material into places it should never reach. Remove the seed from the error message; keep the algorithm and the underlying decoder error for debuggability. Add a regression test that constructs a Wallet with a bogus seed and asserts the seed string is not present in str(exc). * fix: redact secret fields in BaseModel __repr__ (#992) repr()/str() of Sign, SignFor, SignAndSubmit, and ChannelAuthorize included the raw `secret`, `seed`, `seed_hex`, and `passphrase` values because @DataClass auto-generated a __repr__ on each subclass that shadowed BaseModel's. That placed secret material anywhere object reprs land: logs, error-tracking systems (Sentry, Datadog), debugger output, and traceback frame locals. Fix centrally on BaseModel: - Define _SENSITIVE_FIELDS listing the redacted field names. - Install BaseModel.__repr__ via __init_subclass__ before @DataClass runs, so the decorator sees __repr__ already defined and skips auto-generating. Every BaseModel subclass now flows through the redacting repr with no per-class opt-in. - Rewrite BaseModel.__repr__ to iterate dataclasses.fields() and emit '***REDACTED***' for sensitive fields. to_dict() is unchanged, so the wire payload still carries real values. * fix: use cryptographic RNG for WebSocket request IDs (#986) Request ID generation used random.randrange over a 1M-element keyspace. Mersenne Twister state is recoverable from observed outputs, so an attacker who can inject WebSocket frames (wss misconfiguration, TLS-intercepting proxy, relay between client and server) could predict upcoming IDs and race a forged response to resolve a pending await with attacker-controlled data — forged balances, fake tesSUCCESS on submit, bogus tx confirmations. The small keyspace also caused birthday collisions on long-lived connections around 1,177 requests, triggering spurious "already in progress" errors without an attacker. Switch to secrets.randbelow and widen _REQ_ID_MAX to 2**62 so both issues go away. IDs are embedded in a string so arbitrary Python ints are fine; no wire-format change. * minor: linter fixes * minor: fix mypy error * minor: Use HIDDEN instead of REDACTED to denote sensitive field-values * tests: address PR comments suggesting wider test coverage (points 4, 5) * integ test: update docker command to be consistent with recent updates to xrpld executable * fix: skip malformed JSON frames in WebSocket handler (#977) A single malformed frame used to propagate json.JSONDecodeError out of _handler, killing the background task and silencing the client for the remainder of the connection. Wrap json.loads in try/except so malformed frames are dropped and the async-for loop continues. Also backfills CHANGELOG entries for this fix and for the earlier cryptographic RNG change (#986), both landing in websocket_base.py. * minor: print warnings to stdout before dropping a malformed frame * minor: address PR comments * minor: remove traces of seed material in error msg
1 parent a68a2f0 commit 69573b2

11 files changed

Lines changed: 310 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- Fixed correct mapping of `sfMutableFlags`, `sfStartDate`, and `sfPreviousPaymentDueDate` fields in the binary codec `definitions.json`.
1818
- Fixed `Amount` codec to correctly handle large integers with trailing zeros (precision is counted by significant digits, not total digits).
19+
- Fixed async WebSocket handler so a single malformed JSON frame is skipped instead of terminating the handler task and silencing the client for the remainder of the connection (issue #977).
20+
- Fixed WebSocket request-ID generation to use a cryptographic RNG (`secrets.randbelow`) and widened the ID range from `1_000_000` to `2**62`, making birthday-paradox collisions astronomically unlikely (expected collision after ~2**31 requests instead of ~1,177) (issue #986).
1921

2022
## [[4.5.0]]
2123

tests/unit/asyn/clients/__init__.py

Whitespace-only changes.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Regression tests for issue #977: a malformed JSON frame must not kill
2+
the WebSocket handler task."""
3+
4+
import asyncio
5+
import io
6+
import json
7+
from contextlib import redirect_stdout
8+
from typing import List
9+
from unittest import IsolatedAsyncioTestCase
10+
11+
from xrpl.asyncio.clients.async_websocket_client import AsyncWebsocketClient
12+
13+
14+
class _FakeWebSocket:
15+
"""Minimal async-iterable stand-in for websockets.ClientConnection.
16+
17+
Yields each frame in ``frames`` and then terminates, which lets
18+
``_handler`` return normally (as it would when the server closes the
19+
connection cleanly)."""
20+
21+
def __init__(self, frames: List[bytes]) -> None:
22+
self._frames = frames
23+
24+
def __aiter__(self) -> "_FakeWebSocket":
25+
self._iter = iter(self._frames)
26+
return self
27+
28+
async def __anext__(self) -> bytes:
29+
try:
30+
return next(self._iter)
31+
except StopIteration:
32+
raise StopAsyncIteration
33+
34+
35+
class TestHandlerMalformedJson(IsolatedAsyncioTestCase):
36+
async def test_handler_survives_malformed_frame(self) -> None:
37+
"""Send three frames: valid, malformed, valid. The handler must
38+
enqueue both valid frames and simply skip the malformed one."""
39+
frames = [
40+
json.dumps({"id": "req_1", "result": "ok"}).encode(),
41+
b"{ this is not valid json",
42+
json.dumps({"id": "req_2", "result": "ok"}).encode(),
43+
]
44+
45+
ws = AsyncWebsocketClient("ws://test")
46+
ws._websocket = _FakeWebSocket(frames) # type: ignore[assignment]
47+
ws._messages = asyncio.Queue()
48+
ws._open_requests = {}
49+
50+
# Must not raise. Before the fix, json.JSONDecodeError on frame 2
51+
# would propagate out and terminate the handler task.
52+
await ws._handler()
53+
54+
# Both valid frames were enqueued; the malformed one was dropped.
55+
enqueued = []
56+
while not ws._messages.empty():
57+
enqueued.append(ws._messages.get_nowait())
58+
self.assertEqual(len(enqueued), 2)
59+
self.assertEqual(enqueued[0], {"id": "req_1", "result": "ok"})
60+
self.assertEqual(enqueued[1], {"id": "req_2", "result": "ok"})
61+
62+
async def test_handler_prints_malformed_frame(self) -> None:
63+
"""The skipped frame must be surfaced on stdout so the failure is
64+
not silent."""
65+
bad_frame = b"{ this is not valid json"
66+
frames = [
67+
json.dumps({"id": "req_1", "result": "ok"}).encode(),
68+
bad_frame,
69+
json.dumps({"id": "req_2", "result": "ok"}).encode(),
70+
]
71+
72+
ws = AsyncWebsocketClient("ws://test")
73+
ws._websocket = _FakeWebSocket(frames) # type: ignore[assignment]
74+
ws._messages = asyncio.Queue()
75+
ws._open_requests = {}
76+
77+
buf = io.StringIO()
78+
with redirect_stdout(buf):
79+
await ws._handler()
80+
output = buf.getvalue()
81+
82+
# The malformed frame's repr must appear in stdout, and the two
83+
# valid frames must not (only the bad one is logged).
84+
self.assertIn(repr(bad_frame), output)
85+
self.assertIn("malformed", output.lower())
86+
self.assertNotIn("req_1", output)
87+
self.assertNotIn("req_2", output)

tests/unit/core/binarycodec/types/test_amount.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,36 @@ def test_from_value_issued_currency(self):
205205
amount_object = amount.Amount.from_value(json)
206206
self.assertEqual(amount_object.to_hex(), serialized)
207207

208+
def test_iou_underflow_rounds_to_zero(self):
209+
"""Regression test for issue #948.
210+
211+
A non-zero IOU value whose normalized mantissa falls below
212+
MIN_IOU_MANTISSA (or whose exponent falls below MIN_IOU_EXPONENT)
213+
must serialize to the canonical zero amount (only the "Not XRP"
214+
bit set), and must round-trip back to "0"."""
215+
issuer = "rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw"
216+
# 8-byte amount ("Not XRP" bit only) + 20-byte currency + 20-byte issuer.
217+
# Canonical zero layout: only the type bit (bit 63) is set; sign,
218+
# exponent, and mantissa are all 0. See:
219+
# https://xrpl.org/docs/references/protocol/binary-format#token-amount-format
220+
zero_amount_hex = "8000000000000000"
221+
usd_currency_hex = "0000000000000000000000005553440000000000"
222+
issuer_hex = "8B1CE810C13D6F337DAC85863B3D70265A24DF44"
223+
zero_hex = zero_amount_hex + usd_currency_hex + issuer_hex
224+
underflow_cases = ["1e-82", "1e-96", "-1e-96"]
225+
for value in underflow_cases:
226+
iou = {"value": value, "currency": "USD", "issuer": issuer}
227+
amount_object = amount.Amount.from_value(iou)
228+
self.assertEqual(
229+
amount_object.to_hex(),
230+
zero_hex,
231+
f"IOU value {value!r} should serialize to canonical zero",
232+
)
233+
round_tripped = amount_object.to_json()
234+
self.assertEqual(round_tripped["value"], "0")
235+
self.assertEqual(round_tripped["currency"], "USD")
236+
self.assertEqual(round_tripped["issuer"], issuer)
237+
208238
def test_from_value_xrp(self):
209239
for json, serialized in XRP_CASES:
210240
amount_object = amount.Amount.from_value(json)

tests/unit/models/requests/test_sign.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,47 @@ def test_valid_seed(self):
8080
transaction=_TRANSACTION, seed=_SEED, key_type=CryptoAlgorithm.SECP256K1
8181
)
8282
self.assertTrue(request.is_valid())
83+
84+
def test_sensitive_fields_HIDDEN_in_repr(self):
85+
"""Regression test for issue #992: secret, seed, seed_hex, and
86+
passphrase must never appear in repr() / str() output, since those
87+
surfaces commonly feed logs and error-reporting pipelines. The raw
88+
values must still round-trip through to_dict() so the RPC payload
89+
is unchanged."""
90+
for field, value in [
91+
("secret", _SECRET),
92+
("seed", _SEED),
93+
("seed_hex", _SEED_HEX),
94+
("passphrase", _PASSPHRASE),
95+
]:
96+
request = Sign(transaction=_TRANSACTION, **{field: value})
97+
self.assertNotIn(value, repr(request), f"{field} leaked via repr")
98+
self.assertNotIn(value, str(request), f"{field} leaked via str")
99+
self.assertIn("-HIDDEN-", repr(request))
100+
self.assertIn("-HIDDEN-", str(request))
101+
self.assertEqual(request.to_dict()[field], value)
102+
103+
def test_non_sensitive_fields_appear_in_repr(self):
104+
"""Redaction must not over-mask: ordinary fields must still appear in
105+
repr() with their real values, and the overall shape must match the
106+
standard dataclass format `ClassName(field=value, ...)`."""
107+
request = Sign(
108+
transaction=_TRANSACTION,
109+
seed=_SEED,
110+
key_type=CryptoAlgorithm.SECP256K1,
111+
offline=True,
112+
fee_mult_max=42,
113+
)
114+
rendered = repr(request)
115+
self.assertTrue(rendered.startswith("Sign("))
116+
self.assertTrue(rendered.endswith(")"))
117+
# Non-sensitive scalar fields render with their real values
118+
self.assertIn("offline=True", rendered)
119+
self.assertIn("fee_mult_max=42", rendered)
120+
self.assertIn("CryptoAlgorithm.SECP256K1", rendered)
121+
# Nested transaction is rendered (not replaced by a placeholder)
122+
self.assertIn("account='r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ'", rendered)
123+
self.assertIn("domain='asjcsodafsaid0f9asdfasdf'", rendered)
124+
# None-valued sensitive fields are rendered as None, not -HIDDEN-
125+
self.assertIn("secret=None", rendered)
126+
self.assertIn("passphrase=None", rendered)

tests/unit/models/test_base_model.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
import json
22
import os
3+
from dataclasses import dataclass
34
from unittest import TestCase
45

6+
from typing_extensions import Self
7+
58
from xrpl.models import XRPLModelException
69
from xrpl.models.amounts import IssuedCurrencyAmount
10+
from xrpl.models.base_model import BaseModel
711
from xrpl.models.currencies import XRP, IssuedCurrency
812
from xrpl.models.requests import (
913
AccountChannels,
1014
BookOffers,
15+
ChannelAuthorize,
1116
PathFind,
1217
PathFindSubcommand,
1318
PathStep,
1419
Request,
1520
Sign,
21+
SignAndSubmit,
22+
SignFor,
1623
SubmitMultisigned,
1724
SubmitOnly,
1825
)
1926
from xrpl.models.requests.request import _DEFAULT_API_VERSION
2027
from xrpl.models.transactions import (
28+
AccountSet,
29+
AccountSetAsfFlag,
2130
AMMBid,
2231
AuthAccount,
2332
CheckCreate,
@@ -70,6 +79,51 @@ def test_repr(self):
7079
)
7180
self.assertEqual(repr(amount), expected_repr)
7281

82+
def test_init_subclass_preserves_subclass_defined_repr(self):
83+
# A subclass that defines its own __repr__ must keep it. The
84+
# __init_subclass__ hook installs the redacting __repr__ only when
85+
# the subclass hasn't provided one; dropping that guard would
86+
# silently overwrite any subclass-defined repr.
87+
@dataclass(frozen=True)
88+
class _CustomModel(BaseModel):
89+
value: str = "x"
90+
91+
def __repr__(self: Self) -> str:
92+
return "CUSTOM_REPR"
93+
94+
self.assertEqual(repr(_CustomModel(value="anything")), "CUSTOM_REPR")
95+
96+
def test_repr_redaction_applies_to_every_signing_request(self):
97+
# The fix for issue #992 lives on BaseModel.__init_subclass__, so
98+
# every subclass should inherit the redacting __repr__. test_sign.py
99+
# only exercises Sign; this test proves the wiring reaches every
100+
# secret-bearing request class. Protects against a future change
101+
# (e.g. adding an explicit @dataclass(repr=True) on one subclass)
102+
# that would force dataclass to regenerate __repr__ and silently
103+
# reintroduce the leak on only that class.
104+
transaction = AccountSet(
105+
account="r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ",
106+
fee="0.00001",
107+
set_flag=AccountSetAsfFlag.ASF_DISALLOW_XRP,
108+
sequence=19048,
109+
)
110+
seed = "sEdTM1uX8pu2do5XvTnutH6HsouMaM2"
111+
cases = [
112+
Sign(transaction=transaction, seed=seed),
113+
SignFor(
114+
account="r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ",
115+
transaction=transaction,
116+
seed=seed,
117+
),
118+
SignAndSubmit(transaction=transaction, seed=seed),
119+
ChannelAuthorize(channel_id="0" * 64, amount="1000", seed=seed),
120+
]
121+
for request in cases:
122+
with self.subTest(request_cls=type(request).__name__):
123+
rendered = repr(request)
124+
self.assertNotIn(seed, rendered)
125+
self.assertIn("seed='-HIDDEN-'", rendered)
126+
73127
def test_is_dict_of_model_when_true(self):
74128
self.assertTrue(
75129
IssuedCurrencyAmount.is_dict_of_model(

tests/unit/wallet/test_wallet.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,37 @@ def test_init_ed25519_with_s_seed(self):
5151
def test_init_secp256k1_with_sEd_seed_fail(self):
5252
with self.assertRaises(XRPLAddressCodecException):
5353
Wallet.from_seed(SED_SEED, algorithm=CryptoAlgorithm.SECP256K1)
54+
55+
def test_invalid_seed_not_leaked_in_exception(self):
56+
"""Regression test for issue #987: exception messages for invalid
57+
seeds must not include the raw seed, since they often get logged or
58+
captured by error-tracking systems."""
59+
secret_seed = "sInvalidSeedXXXXXXXXXXXXXX"
60+
with self.assertRaises(XRPLAddressCodecException) as ctx:
61+
Wallet(public_key="abc", private_key="def", seed=secret_seed)
62+
self.assertNotIn(secret_seed, str(ctx.exception))
63+
64+
def test_invalid_seed_chars_not_leaked_via_base58_value_error(self):
65+
"""The XRPL base58 alphabet excludes '0', 'O', 'I', and 'l'. When a
66+
seed contains any of these, ``base58.b58decode`` raises
67+
``ValueError("Invalid character '0' ...")`` with the offending byte
68+
embedded in the message. ``Wallet.__init__`` must neither concatenate
69+
``str(e)`` into its own message nor leave the original exception
70+
attached as ``__cause__`` / ``__context__``, since either path leaks
71+
a character of the seed into logs and tracebacks.
72+
"""
73+
# Every character below is in the base58 alphabet *except* '0', so
74+
# any '0' in the message or chained cause could only have come from
75+
# the seed.
76+
seed = "sZZZZZZZZZZZZZZZZZZZZZZZZ0"
77+
with self.assertRaises(XRPLAddressCodecException) as ctx:
78+
Wallet(public_key="abc", private_key="def", seed=seed)
79+
80+
exc = ctx.exception
81+
self.assertNotIn(seed, str(exc))
82+
self.assertNotIn("0", str(exc))
83+
# ``raise ... from None`` must suppress the chained cause so that
84+
# the original ``ValueError("Invalid character '0'")`` can't reach
85+
# full-traceback log sinks.
86+
self.assertIsNone(exc.__cause__)
87+
self.assertTrue(exc.__suppress_context__)

xrpl/asyncio/clients/websocket_base.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import asyncio
66
import json
7-
from random import randrange
7+
from secrets import randbelow
88
from typing import TYPE_CHECKING, Any, Dict, Optional, cast
99

1010
from typing_extensions import Final, Self
@@ -18,7 +18,10 @@
1818
from xrpl.models.response import Response
1919

2020
_PAYLOAD_MAX_SIZE: Final[int] = 2**24
21-
_REQ_ID_MAX: Final[int] = 1_000_000
21+
# Widened from 1_000_000 to 2**62 so birthday-paradox collisions are
22+
# astronomically unlikely (expected collision after ~2**31 requests instead
23+
# of ~1,177). IDs are embedded in a string, so arbitrary Python ints are fine.
24+
_REQ_ID_MAX: Final[int] = 2**62
2225

2326
# the types from asyncio are not implemented as generics in python 3.8 and
2427
# lower, so we need to only subscript them when running typechecking.
@@ -47,7 +50,7 @@ def _inject_request_id(request: Request) -> Request:
4750
if request.id is not None:
4851
return request
4952
request_dict = request.to_dict()
50-
request_dict["id"] = f"{request.method}_{randrange(_REQ_ID_MAX)}"
53+
request_dict["id"] = f"{request.method}_{randbelow(_REQ_ID_MAX)}"
5154
return Request.from_dict(request_dict)
5255

5356

@@ -133,7 +136,16 @@ async def _handler(self: Self) -> None:
133136
As long as a given client remains open, this handler will be running as a Task.
134137
"""
135138
async for response in cast(websocket_client.ClientConnection, self._websocket):
136-
response_dict = json.loads(response)
139+
try:
140+
response_dict = json.loads(response)
141+
except json.JSONDecodeError:
142+
# Issue #977: a single malformed frame must not kill the
143+
# handler task and leave the client deaf to every frame
144+
# that follows. Surface the dropped frame so the failure is
145+
# not silent.
146+
# TODO: Introduce a module-level logging system across xrpl-py library
147+
print(f"xrpl-py: skipping malformed WebSocket frame: {response!r}")
148+
continue
137149

138150
# if this response corresponds to request, fulfill the Future
139151
if "id" in response_dict and response_dict["id"] in self._open_requests:

xrpl/core/binarycodec/types/amount.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def _serialize_issued_currency_value(value: str) -> bytes:
209209

210210
if exp < MIN_IOU_EXPONENT or mantissa < MIN_IOU_MANTISSA:
211211
# Round to zero
212-
_ZERO_CURRENCY_AMOUNT_HEX.to_bytes(8, byteorder="big", signed=False)
212+
return _ZERO_CURRENCY_AMOUNT_HEX.to_bytes(8, byteorder="big", signed=False)
213213

214214
if exp > MAX_IOU_EXPONENT or mantissa > MAX_IOU_MANTISSA:
215215
raise XRPLBinaryCodecException(

0 commit comments

Comments
 (0)