Skip to content

Commit 8a5a403

Browse files
Reject trailing bytes in LeaderResponse body decode
Both LeaderResponse.decode_body (modern node_id + address) and decode_body_legacy (address-only, pre-1.0 servers) discarded the consumed-byte count from decode_text and never verified the buffer was fully consumed. Trailing bytes after the padded address were silently dropped — the last variable-length cluster-topology decoders still permissive after the strict-decode sweep. LeaderResponse feeds ClusterClient.find_leader, which uses the advertised address to open a new connection. Corrupt frames with trailing garbage previously consumed subsequent messages' framing and produced symptoms far from the cause; reject them at the decoder boundary instead. Tests pin exact-body round-trips and trailing-byte rejection for both schemas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b7d014 commit 8a5a403

2 files changed

Lines changed: 70 additions & 2 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,18 @@ def decode_body(cls, data: bytes, schema: int = 0) -> "LeaderResponse":
153153
node_id, use decode_body_legacy() instead.
154154
"""
155155
node_id = decode_uint64(data)
156-
address, _ = decode_text(data[8:])
156+
address, consumed = decode_text(data[8:])
157157
if len(address) > _MAX_ADDRESS_SIZE:
158158
raise DecodeError(
159159
f"leader address length {len(address)} exceeds maximum {_MAX_ADDRESS_SIZE}"
160160
)
161+
offset = 8 + consumed
162+
if offset != len(data):
163+
# Strict-decode parity with sibling decoders: conforming
164+
# Go/C servers never emit trailing padding on this body.
165+
raise DecodeError(
166+
f"LeaderResponse has {len(data) - offset} trailing bytes after address"
167+
)
161168
return cls(node_id, _sanitize_server_text(address))
162169

163170
@classmethod
@@ -167,11 +174,15 @@ def decode_body_legacy(cls, data: bytes) -> "LeaderResponse":
167174
Legacy format: text address only (no node_id). Returns node_id=0.
168175
Go reference: DecodeNodeLegacy in internal/protocol/message.go.
169176
"""
170-
address, _ = decode_text(data)
177+
address, consumed = decode_text(data)
171178
if len(address) > _MAX_ADDRESS_SIZE:
172179
raise DecodeError(
173180
f"leader address length {len(address)} exceeds maximum {_MAX_ADDRESS_SIZE}"
174181
)
182+
if consumed != len(data):
183+
raise DecodeError(
184+
f"LeaderResponse (legacy) has {len(data) - consumed} trailing bytes after address"
185+
)
175186
return cls(node_id=0, address=_sanitize_server_text(address))
176187

177188

tests/test_responses_strict_length.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,63 @@ def test_empty_list_trailing_bytes_rejected(self) -> None:
235235
ServersResponse.decode_body(body)
236236

237237

238+
class TestLeaderResponseStrictLength:
239+
"""Modern body: uint64 node_id + padded text address. Legacy body:
240+
padded text address only. Trailing bytes after the address had
241+
been silently dropped."""
242+
243+
@staticmethod
244+
def _modern_body(node_id: int = 7, addr: str = "1.2.3.4:9001") -> bytes:
245+
from dqlitewire.types import encode_text, encode_uint64
246+
247+
return encode_uint64(node_id) + encode_text(addr)
248+
249+
def test_modern_exact_round_trip(self) -> None:
250+
from dqlitewire.messages.responses import LeaderResponse
251+
252+
msg = LeaderResponse.decode_body(self._modern_body())
253+
assert msg.node_id == 7
254+
assert msg.address == "1.2.3.4:9001"
255+
256+
def test_modern_trailing_byte_rejected(self) -> None:
257+
from dqlitewire.messages.responses import LeaderResponse
258+
259+
body = self._modern_body() + b"\x01"
260+
with pytest.raises(DecodeError, match=r"LeaderResponse has 1 trailing byte"):
261+
LeaderResponse.decode_body(body)
262+
263+
def test_modern_trailing_word_rejected(self) -> None:
264+
from dqlitewire.messages.responses import LeaderResponse
265+
266+
body = self._modern_body() + b"\x00" * 8
267+
with pytest.raises(DecodeError, match=r"LeaderResponse has 8 trailing byte"):
268+
LeaderResponse.decode_body(body)
269+
270+
def test_legacy_exact_round_trip(self) -> None:
271+
from dqlitewire.messages.responses import LeaderResponse
272+
from dqlitewire.types import encode_text
273+
274+
msg = LeaderResponse.decode_body_legacy(encode_text("10.0.0.1:9001"))
275+
assert msg.node_id == 0
276+
assert msg.address == "10.0.0.1:9001"
277+
278+
def test_legacy_trailing_byte_rejected(self) -> None:
279+
from dqlitewire.messages.responses import LeaderResponse
280+
from dqlitewire.types import encode_text
281+
282+
body = encode_text("10.0.0.1:9001") + b"\x01"
283+
with pytest.raises(DecodeError, match=r"LeaderResponse \(legacy\) has 1 trailing byte"):
284+
LeaderResponse.decode_body_legacy(body)
285+
286+
def test_legacy_trailing_word_rejected(self) -> None:
287+
from dqlitewire.messages.responses import LeaderResponse
288+
from dqlitewire.types import encode_text
289+
290+
body = encode_text("10.0.0.1:9001") + b"\x00" * 8
291+
with pytest.raises(DecodeError, match=r"LeaderResponse \(legacy\) has 8 trailing byte"):
292+
LeaderResponse.decode_body_legacy(body)
293+
294+
238295
class TestMetadataResponseStrictLength:
239296
"""Body is two uint64s (failure_domain + weight) — exactly 16 bytes."""
240297

0 commit comments

Comments
 (0)