Skip to content

Commit 62c7cac

Browse files
Cap server-address length in LeaderResponse and ServersResponse decoders
Cluster addresses are small by any realistic measure (hostname + port, or IPv6 literal + port). A frame-legal response carrying a multi-MB "address" is malicious or broken and would amplify through logs and exception messages even after _sanitize_server_text. Bound each address at _MAX_ADDRESS_SIZE (256 bytes), applied in LeaderResponse.decode_body, LeaderResponse.decode_body_legacy, and the ServersResponse per-node loop.
1 parent d661830 commit 62c7cac

2 files changed

Lines changed: 72 additions & 0 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@
5858
# PATH_MAX is 4 KiB and mirrors the column-name cap.
5959
_MAX_FILENAME_SIZE = 4096
6060

61+
# Per-address cap on ``LeaderResponse`` / ``ServersResponse`` (and their
62+
# legacy variants). Legitimate cluster addresses are small (hostname
63+
# + port, or IPv6 literal in brackets + port); RFC 1035 sets domain
64+
# names at ≤253 bytes and 256 leaves margin for the port. A multi-MB
65+
# "address" is malicious or broken and would amplify through log /
66+
# exception messages even after ``_sanitize_server_text``.
67+
_MAX_ADDRESS_SIZE = 256
68+
6169
# Sanitize server-supplied text destined for exception messages and
6270
# logs. The C server promises UTF-8 but makes no promise about terminal
6371
# escapes or log-injection characters: a malicious or compromised peer
@@ -145,6 +153,10 @@ def decode_body(cls, data: bytes, schema: int = 0) -> "LeaderResponse":
145153
"""
146154
node_id = decode_uint64(data)
147155
address, _ = decode_text(data[8:])
156+
if len(address) > _MAX_ADDRESS_SIZE:
157+
raise DecodeError(
158+
f"leader address length {len(address)} exceeds maximum {_MAX_ADDRESS_SIZE}"
159+
)
148160
return cls(node_id, _sanitize_server_text(address))
149161

150162
@classmethod
@@ -155,6 +167,10 @@ def decode_body_legacy(cls, data: bytes) -> "LeaderResponse":
155167
Go reference: DecodeNodeLegacy in internal/protocol/message.go.
156168
"""
157169
address, _ = decode_text(data)
170+
if len(address) > _MAX_ADDRESS_SIZE:
171+
raise DecodeError(
172+
f"leader address length {len(address)} exceeds maximum {_MAX_ADDRESS_SIZE}"
173+
)
158174
return cls(node_id=0, address=_sanitize_server_text(address))
159175

160176

@@ -672,6 +688,10 @@ def decode_body(cls, data: bytes, schema: int = 0) -> "ServersResponse":
672688
node_id = decode_uint64(view[offset:])
673689
offset += 8
674690
address, consumed = decode_text(view[offset:])
691+
if len(address) > _MAX_ADDRESS_SIZE:
692+
raise DecodeError(
693+
f"server address length {len(address)} exceeds maximum {_MAX_ADDRESS_SIZE}"
694+
)
675695
address = _sanitize_server_text(address)
676696
offset += consumed
677697
raw_role = decode_uint64(view[offset:])

tests/test_messages_responses.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dqlitewire.exceptions import DecodeError, EncodeError
77
from dqlitewire.messages.base import Header
88
from dqlitewire.messages.responses import (
9+
_MAX_ADDRESS_SIZE,
910
_MAX_COLUMN_NAME_SIZE,
1011
_MAX_FAILURE_MESSAGE_SIZE,
1112
_MAX_FILENAME_SIZE,
@@ -101,6 +102,34 @@ def test_decode_body_legacy(self) -> None:
101102
assert decoded.address == "192.168.1.1:9001"
102103

103104

105+
class TestLeaderResponseAddressSize:
106+
"""Per-address length cap applies to modern and legacy decoders.
107+
108+
Legitimate addresses are short (hostname + port, or IPv6 literal
109+
in brackets + port). Cap at ``_MAX_ADDRESS_SIZE`` so an oversize
110+
peer-supplied string cannot amplify through logs / exception
111+
messages even after sanitization.
112+
"""
113+
114+
def test_decode_rejects_oversize_address(self) -> None:
115+
oversize = "a" * (_MAX_ADDRESS_SIZE + 1)
116+
body = encode_uint64(1) + encode_text(oversize)
117+
with pytest.raises(DecodeError, match="leader address"):
118+
LeaderResponse.decode_body(body)
119+
120+
def test_decode_accepts_address_at_cap(self) -> None:
121+
at_cap = "a" * _MAX_ADDRESS_SIZE
122+
body = encode_uint64(1) + encode_text(at_cap)
123+
decoded = LeaderResponse.decode_body(body)
124+
assert decoded.address == at_cap
125+
126+
def test_decode_body_legacy_rejects_oversize_address(self) -> None:
127+
oversize = "a" * (_MAX_ADDRESS_SIZE + 1)
128+
body = encode_text(oversize)
129+
with pytest.raises(DecodeError, match="leader address"):
130+
LeaderResponse.decode_body_legacy(body)
131+
132+
104133
class TestWelcomeResponse:
105134
def test_roundtrip(self) -> None:
106135
msg = WelcomeResponse(heartbeat_timeout=15000)
@@ -1226,6 +1255,29 @@ def test_truncated_file_content_raises(self) -> None:
12261255
FilesResponse.decode_body(body)
12271256

12281257

1258+
class TestServersResponseAddressSize:
1259+
"""Per-node address length cap in ServersResponse decode."""
1260+
1261+
def _build_body(self, address: str) -> bytes:
1262+
# One node: uint64 id, text address, uint64 role.
1263+
return (
1264+
encode_uint64(1)
1265+
+ encode_uint64(1)
1266+
+ encode_text(address)
1267+
+ encode_uint64(2) # NodeRole.VOTER
1268+
)
1269+
1270+
def test_decode_rejects_oversize_address(self) -> None:
1271+
oversize = "a" * (_MAX_ADDRESS_SIZE + 1)
1272+
with pytest.raises(DecodeError, match="server address"):
1273+
ServersResponse.decode_body(self._build_body(oversize))
1274+
1275+
def test_decode_accepts_address_at_cap(self) -> None:
1276+
at_cap = "a" * _MAX_ADDRESS_SIZE
1277+
decoded = ServersResponse.decode_body(self._build_body(at_cap))
1278+
assert decoded.nodes[0].address == at_cap
1279+
1280+
12291281
class TestFilesResponseFilenameSize:
12301282
"""Per-filename length cap in FilesResponse decode.
12311283

0 commit comments

Comments
 (0)