Skip to content

Commit ad1c35e

Browse files
authored
Merge branch 'master' into refactor/speed-aggregation-tests
2 parents cbe3afa + 343dc7a commit ad1c35e

12 files changed

Lines changed: 181 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
<!-- version list -->
44

5+
## v0.149.13 (2026-05-20)
6+
7+
### Bug Fixes
8+
9+
- Bound record payload reads against rdlength overrun
10+
([#1756](https://github.com/python-zeroconf/python-zeroconf/pull/1756),
11+
[`5444495`](https://github.com/python-zeroconf/python-zeroconf/commit/544449596e645fcaad3834fa0cb614a54f847a82))
12+
13+
### Documentation
14+
15+
- Clarify LGPL-2.1-or-later license in README
16+
([#1763](https://github.com/python-zeroconf/python-zeroconf/pull/1763),
17+
[`28bb01f`](https://github.com/python-zeroconf/python-zeroconf/commit/28bb01f23951f7883d8c3af66b6d537c34c516c7))
18+
19+
### Refactoring
20+
21+
- Extract loopback Zeroconf fixtures and mock_incoming_msg helper
22+
([#1758](https://github.com/python-zeroconf/python-zeroconf/pull/1758),
23+
[`cb0af4a`](https://github.com/python-zeroconf/python-zeroconf/commit/cb0af4a8f4cdaad3721a2851d9fa17709d39ae62))
24+
25+
526
## v0.149.12 (2026-05-20)
627

728
### Bug Fixes

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,10 @@ Changelog
151151
License
152152
=======
153153

154-
LGPL, see COPYING file for details.
154+
GNU Lesser General Public License v2.1 or later (LGPL-2.1-or-later).
155+
156+
The full text of LGPL 2.1 is included in the `COPYING <COPYING>`_ file.
157+
You may, at your option, use this library under the terms of any later
158+
version of the LGPL published by the Free Software Foundation. The
159+
canonical SPDX identifier for this project is ``LGPL-2.1-or-later``, as
160+
declared in ``pyproject.toml``.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[project]
66
name = "zeroconf"
7-
version = "0.149.12"
7+
version = "0.149.13"
88
license = "LGPL-2.1-or-later"
99
description = "A pure python implementation of multicast DNS service discovery"
1010
readme = "README.rst"

src/zeroconf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888

8989
__author__ = "Paul Scott-Murphy, William McBrine"
9090
__maintainer__ = "Jakub Stasiak <jakub@stasiak.at>"
91-
__version__ = "0.149.12"
91+
__version__ = "0.149.13"
9292
__license__ = "LGPL"
9393

9494

src/zeroconf/_core.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@
104104

105105
_REGISTER_BROADCASTS = 3
106106

107+
# RFC 6762 §8.1 thundering-herd avoidance: wait a random
108+
# 0-250ms before the first probe so simultaneously-started
109+
# responders don't collide. We default to 150-250ms to
110+
# preserve existing timing assumptions; tests on loopback
111+
# may patch this lower via the `quick_timing` fixture.
112+
_PROBE_RANDOM_DELAY_INTERVAL = (150, 250) # ms
113+
107114

108115
def async_send_with_transport(
109116
log_debug: bool,
@@ -561,7 +568,7 @@ async def async_check_service(
561568

562569
# Wait a random amount of time up avoid collisions and avoid
563570
# a thundering herd when multiple services are started on the network
564-
await self.async_wait(random.randint(150, 250)) # noqa: S311
571+
await self.async_wait(random.randint(*_PROBE_RANDOM_DELAY_INTERVAL)) # noqa: S311
565572

566573
next_instance_number = 2
567574
next_time = now = current_time_millis()

src/zeroconf/_protocol/incoming.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,27 @@ def _read_character_string(self) -> str:
254254
"""Reads a character string from the packet"""
255255
length = self.view[self.offset]
256256
self.offset += 1
257+
# Python slicing silently truncates when indices exceed the buffer,
258+
# but self.offset still advances by the declared length below; without
259+
# this check a record with an inflated character-string length would
260+
# land in the cache carrying a payload shorter than the wire claimed
261+
# and leave the parser pointed past _data_len for the next record.
262+
if self.offset + length > self._data_len:
263+
raise IncomingDecodeError(
264+
f"Character string length {length} at offset {self.offset} overruns "
265+
f"packet of {self._data_len} bytes from {self.source}"
266+
)
257267
info = self.data[self.offset : self.offset + length].decode("utf-8", "replace")
258268
self.offset += length
259269
return info
260270

261271
def _read_string(self, length: _int) -> bytes:
262272
"""Reads a string of a given length from the packet"""
273+
if self.offset + length > self._data_len:
274+
raise IncomingDecodeError(
275+
f"String length {length} at offset {self.offset} overruns "
276+
f"packet of {self._data_len} bytes from {self.source}"
277+
)
263278
info = self.data[self.offset : self.offset + length]
264279
self.offset += length
265280
return info
@@ -297,6 +312,19 @@ def _read_others(self) -> None:
297312
self.data,
298313
exc_info=True,
299314
)
315+
if rec is not None and self.offset != end:
316+
# The decoded record consumed a different number of bytes than
317+
# rdlength advertised. The record is built from a slice that
318+
# straddles its rdata boundary, so drop it and resync to the
319+
# declared end so the next record header lands aligned.
320+
log.debug(
321+
"Record for %s with type %s did not consume exactly rdlength=%d; dropping",
322+
domain,
323+
_TYPES.get(type_, type_),
324+
length,
325+
)
326+
self.offset = end
327+
rec = None
300328
if rec is not None:
301329
self._answers.append(rec)
302330

tests/conftest.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,22 @@ def quick_timing() -> Generator[None]:
8282
"""Shorten the probe/announce/goodbye/first-query intervals for tests on loopback.
8383
8484
The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms,
85-
_UNREGISTER_TIME=125ms, _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms)
86-
exist for RFC 6762 interop on real networks (§8.1 thundering-herd
87-
avoidance for probing, §5.2 for the initial-query delay). Tests on
88-
127.0.0.1 do not need them and pay 1-2s per register/unregister
89-
cycle and 20-120ms per ServiceBrowser startup without this fixture.
90-
Opt in by adding `quick_timing` to a test's argument list.
85+
_UNREGISTER_TIME=125ms, _PROBE_RANDOM_DELAY_INTERVAL=150-250ms,
86+
_FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms) exist for RFC 6762
87+
interop on real networks (§8.1 thundering-herd avoidance for
88+
probing, §5.2 for the initial-query delay). Tests on 127.0.0.1
89+
do not need them and pay 1-2s per register/unregister cycle,
90+
150-250ms per probe, and 20-120ms per ServiceBrowser startup
91+
without this fixture. Opt in either by adding `quick_timing`
92+
to a test's argument list or via
93+
`@pytest.mark.usefixtures("quick_timing")` on the test or
94+
its class.
9195
"""
9296
with (
9397
patch.object(_core, "_CHECK_TIME", 10),
9498
patch.object(_core, "_REGISTER_TIME", 10),
9599
patch.object(_core, "_UNREGISTER_TIME", 10),
100+
patch.object(_core, "_PROBE_RANDOM_DELAY_INTERVAL", (1, 5)),
96101
patch.object(service_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (1, 5)),
97102
):
98103
yield
@@ -132,9 +137,11 @@ def quick_request_timing() -> Generator[None]:
132137
The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762
133138
§5.2) help spread queries from multiple clients on real networks.
134139
On loopback they're pure overhead — get_service_info-style tests
135-
wait ~250ms before the first query even fires. Opt in by adding
136-
`quick_request_timing` to a test's argument list, then drop the
137-
test's own timeouts (which had to accommodate that delay).
140+
wait ~250ms before the first query even fires. Opt in either by
141+
adding `quick_request_timing` to a test's argument list or via
142+
`@pytest.mark.usefixtures("quick_request_timing")` on the test
143+
or its class, then drop the test's own timeouts (which had to
144+
accommodate that delay).
138145
"""
139146
with (
140147
patch.object(service_info, "_LISTENER_TIME", 10),

tests/services/test_info.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def test_service_info_rejects_expired_records(self):
251251

252252
@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
253253
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
254+
@pytest.mark.usefixtures("quick_request_timing")
254255
def test_get_info_partial(self):
255256
zc = r.Zeroconf(interfaces=["127.0.0.1"])
256257

tests/test_asyncio.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ async def test_async_wait_unblocks_on_update(quick_timing: None) -> None:
608608

609609

610610
@pytest.mark.asyncio
611-
async def test_service_info_async_request(quick_timing: None) -> None:
611+
async def test_service_info_async_request(quick_timing: None, quick_request_timing: None) -> None:
612612
"""Test registering services broadcasts and query with AsyncServceInfo.async_request."""
613613
if not has_working_ipv6() or os.environ.get("SKIP_IPV6"):
614614
pytest.skip("Requires IPv6")
@@ -710,13 +710,13 @@ async def test_service_info_async_request(quick_timing: None) -> None:
710710
aiozc.zeroconf.out_delay_queue.queue.clear()
711711
aiosinfo = AsyncServiceInfo(type_, registration_name)
712712
_clear_cache(aiozc.zeroconf)
713-
# Generating the race condition is almost impossible
714-
# without patching since its a TOCTOU race. 1500ms covers
715-
# the initial _LISTENER_TIME + random delay (200-320ms) and
716-
# leaves plenty of margin for the loopback response to land
713+
# Generating the race condition is almost impossible without
714+
# patching since it's a TOCTOU race. Under `quick_request_timing`
715+
# the first QU query fires at ~10ms and the QM follow-up at ~15ms;
716+
# 300ms leaves plenty of margin for the loopback response to land
717717
# before the loop times out.
718718
with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False):
719-
await aiosinfo.async_request(aiozc.zeroconf, 1500)
719+
await aiosinfo.async_request(aiozc.zeroconf, 300)
720720
assert aiosinfo is not None
721721
assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")]
722722

tests/test_protocol.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,89 @@ def test_nsec_bitmap_truncated_window_header_rejected():
886886
assert not any(isinstance(a, r.DNSNsec) for a in answers)
887887

888888

889+
def test_txt_rdlength_overruns_packet_rejected():
890+
"""A TXT record with rdlength past the buffer must not enter the cache.
891+
892+
Python slicing silently truncates when the slice end exceeds the buffer,
893+
so without a bounds check in ``_read_string`` a malformed wire frame
894+
would land in the cache carrying a payload shorter than the rdlength
895+
declared, leaving the parser desynchronized for downstream records.
896+
"""
897+
packet = (
898+
b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00"
899+
b"\x04test\x05local\x00"
900+
b"\x00\x10\x80\x01"
901+
b"\x00\x00\x11\x94"
902+
b"\xff\xff"
903+
b"\x05hello"
904+
)
905+
parsed = r.DNSIncoming(packet)
906+
assert parsed.valid
907+
assert parsed.answers() == []
908+
909+
910+
def test_hinfo_character_string_length_overruns_record_rejected():
911+
"""A HINFO character string declaring more bytes than remain must be rejected."""
912+
packet = (
913+
b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00"
914+
b"\x04test\x05local\x00"
915+
b"\x00\x0d\x80\x01"
916+
b"\x00\x00\x11\x94"
917+
b"\x00\x07"
918+
b"\x03cpu"
919+
b"\xff\xff\xff"
920+
)
921+
parsed = r.DNSIncoming(packet)
922+
assert parsed.valid
923+
assert not any(isinstance(a, r.DNSHinfo) for a in parsed.answers())
924+
925+
926+
def test_a_record_rdlength_overruns_packet_rejected():
927+
"""An A record whose 4-byte address would walk past the buffer must be rejected."""
928+
packet = (
929+
b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00"
930+
b"\x04test\x05local\x00"
931+
b"\x00\x01\x80\x01"
932+
b"\x00\x00\x11\x94"
933+
b"\x00\x04"
934+
b"\xc0\xa8"
935+
)
936+
parsed = r.DNSIncoming(packet)
937+
assert parsed.valid
938+
assert not any(isinstance(a, r.DNSAddress) for a in parsed.answers())
939+
940+
941+
def test_record_consuming_more_than_rdlength_dropped_and_resyncs():
942+
"""A record whose decoded fields overrun its rdlength must drop and resync.
943+
944+
The first answer is a HINFO with ``rdlength=2`` and rdata ``\\x01x`` (one
945+
char string ``"x"``). The second character string's length byte then comes
946+
from the next record's name (``\\x00``, root domain), so the HINFO would
947+
silently parse as ``cpu="x", os=""`` but leave the offset one byte past
948+
the declared end, smearing the second record's framing. With the per-record
949+
boundary check the bogus HINFO is dropped and the second record decodes.
950+
"""
951+
packet = (
952+
b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
953+
b"\x04test\x05local\x00"
954+
b"\x00\x0d\x80\x01"
955+
b"\x00\x00\x11\x94"
956+
b"\x00\x02"
957+
b"\x01x"
958+
b"\x00"
959+
b"\x00\x0c\x00\x01"
960+
b"\x00\x00\x11\x94"
961+
b"\x00\x02"
962+
b"\xc0\x0c"
963+
)
964+
parsed = r.DNSIncoming(packet)
965+
answers = parsed.answers()
966+
assert not any(isinstance(a, r.DNSHinfo) for a in answers)
967+
ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
968+
assert len(ptrs) == 1
969+
assert ptrs[0].alias == "test.local."
970+
971+
889972
def test_records_same_packet_share_fate():
890973
"""Test records in the same packet all have the same created time."""
891974
out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA)

0 commit comments

Comments
 (0)