Skip to content

Commit b9cf03a

Browse files
Reject trailing bytes in FailureResponse body decode
FailureResponse discarded the consumed-byte count from decode_text and never verified the buffer was fully consumed. FailureResponse carries operator-visible error strings that propagate into DB-API exceptions; trailing bytes after the padded message were silently dropped — a corrupt frame that mutated bytes after the message still decoded successfully while the next message's framing re-synced on stale bytes. Mirror the strict-decode pattern now applied to the sibling variable-length decoders (ServersResponse, LeaderResponse). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8a5a403 commit b9cf03a

2 files changed

Lines changed: 40 additions & 1 deletion

File tree

src/dqlitewire/messages/responses.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,18 @@ def encode_body(self) -> bytes:
121121
@classmethod
122122
def decode_body(cls, data: bytes, schema: int = 0) -> "FailureResponse":
123123
code = decode_uint64(data)
124-
message, _ = decode_text(data[8:])
124+
message, consumed = decode_text(data[8:])
125125
if len(message) > _MAX_FAILURE_MESSAGE_SIZE:
126126
raise DecodeError(
127127
f"Failure message length {len(message)} exceeds maximum {_MAX_FAILURE_MESSAGE_SIZE}"
128128
)
129+
offset = 8 + consumed
130+
if offset != len(data):
131+
# Strict-decode parity with sibling decoders: conforming
132+
# Go/C servers never emit trailing padding on this body.
133+
raise DecodeError(
134+
f"FailureResponse has {len(data) - offset} trailing bytes after message"
135+
)
129136
return cls(code, _sanitize_server_text(message))
130137

131138

tests/test_responses_strict_length.py

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

237237

238+
class TestFailureResponseStrictLength:
239+
"""Body: uint64 code + padded text message. Trailing bytes after
240+
the padded message had been silently accepted."""
241+
242+
@staticmethod
243+
def _body(code: int = 5, message: str = "database is locked") -> bytes:
244+
from dqlitewire.types import encode_text, encode_uint64
245+
246+
return encode_uint64(code) + encode_text(message)
247+
248+
def test_exact_round_trip(self) -> None:
249+
from dqlitewire.messages.responses import FailureResponse
250+
251+
msg = FailureResponse.decode_body(self._body())
252+
assert msg.code == 5
253+
assert msg.message == "database is locked"
254+
255+
def test_trailing_byte_rejected(self) -> None:
256+
from dqlitewire.messages.responses import FailureResponse
257+
258+
body = self._body() + b"\x01"
259+
with pytest.raises(DecodeError, match=r"FailureResponse has 1 trailing byte"):
260+
FailureResponse.decode_body(body)
261+
262+
def test_trailing_word_rejected(self) -> None:
263+
from dqlitewire.messages.responses import FailureResponse
264+
265+
body = self._body() + b"\x00" * 8
266+
with pytest.raises(DecodeError, match=r"FailureResponse has 8 trailing byte"):
267+
FailureResponse.decode_body(body)
268+
269+
238270
class TestLeaderResponseStrictLength:
239271
"""Modern body: uint64 node_id + padded text address. Legacy body:
240272
padded text address only. Trailing bytes after the address had

0 commit comments

Comments
 (0)