Skip to content

Commit d3598be

Browse files
authored
Merge branch 'master' into fix/seen-logs-unbounded-retention
2 parents 0e25328 + 65b22cb commit d3598be

6 files changed

Lines changed: 66 additions & 7 deletions

File tree

CHANGELOG.md

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

33
<!-- version list -->
44

5+
## v0.149.5 (2026-05-18)
6+
7+
### Bug Fixes
8+
9+
- Bound DNS compression-pointer chain depth in DNSIncoming
10+
([#1719](https://github.com/python-zeroconf/python-zeroconf/pull/1719),
11+
[`f9e2359`](https://github.com/python-zeroconf/python-zeroconf/commit/f9e23592137f30fdf7ef710dba065da31c79b1cf))
12+
13+
### Testing
14+
15+
- Cap helper get_service_info timeout in suppression test (~3.0s → ~1.85s)
16+
([#1708](https://github.com/python-zeroconf/python-zeroconf/pull/1708),
17+
[`ee3c7d7`](https://github.com/python-zeroconf/python-zeroconf/commit/ee3c7d74ff45327a3a6d520b86a691e21e2bc219))
18+
19+
- Drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback
20+
([#1710](https://github.com/python-zeroconf/python-zeroconf/pull/1710),
21+
[`64d143d`](https://github.com/python-zeroconf/python-zeroconf/commit/64d143d2ee7874ee1d9cef0dd2799c008b4aa791))
22+
23+
- Eliminate test_get_info_single race by injecting from the send mock
24+
([#1716](https://github.com/python-zeroconf/python-zeroconf/pull/1716),
25+
[`963d3d7`](https://github.com/python-zeroconf/python-zeroconf/commit/963d3d70e1cde056967eba0d8747ddcd247ae707))
26+
27+
- Fix race in test_register_and_lookup_type_by_uppercase_name
28+
([#1712](https://github.com/python-zeroconf/python-zeroconf/pull/1712),
29+
[`91aa21d`](https://github.com/python-zeroconf/python-zeroconf/commit/91aa21d52a0873f5fc12d43675b1b521dfe20519))
30+
31+
- Speed up service-info request tests with quick_request_timing fixture
32+
([#1709](https://github.com/python-zeroconf/python-zeroconf/pull/1709),
33+
[`4bae30a`](https://github.com/python-zeroconf/python-zeroconf/commit/4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31))
34+
35+
536
## v0.149.4 (2026-05-17)
637

738
### Bug Fixes

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.4"
7+
version = "0.149.5"
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.4"
91+
__version__ = "0.149.5"
9292
__license__ = "LGPL"
9393

9494

src/zeroconf/_protocol/incoming.pxd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ cdef class DNSIncoming:
8383
link_py_int=object,
8484
linked_labels=cython.list
8585
)
86-
cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers)
86+
cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers, unsigned int depth)
8787

8888
@cython.locals(offset="unsigned int")
8989
cdef void _read_header(self)

src/zeroconf/_protocol/incoming.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
MAX_DNS_LABELS = 128
6161
MAX_NAME_LENGTH = 253
6262

63-
DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError)
63+
DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError, RecursionError)
6464

6565

6666
_seen_logs: dict[str, None] = {}
@@ -403,7 +403,7 @@ def _read_name(self) -> str:
403403
labels: list[str] = []
404404
seen_pointers: set[int] = set()
405405
original_offset = self.offset
406-
self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers)
406+
self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers, 0)
407407
self._name_cache[original_offset] = labels
408408
name = ".".join(labels) + "."
409409
if len(name) > MAX_NAME_LENGTH:
@@ -412,8 +412,14 @@ def _read_name(self) -> str:
412412
)
413413
return name
414414

415-
def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int:
415+
def _decode_labels_at_offset(
416+
self, off: _int, labels: list[str], seen_pointers: set[int], depth: _int
417+
) -> int:
416418
# This is a tight loop that is called frequently, small optimizations can make a difference.
419+
if depth > MAX_DNS_LABELS:
420+
raise IncomingDecodeError(
421+
f"DNS compression pointer chain exceeds {MAX_DNS_LABELS} at {off} from {self.source}"
422+
)
417423
view = self.view
418424
while off < self._data_len:
419425
length = view[off]
@@ -451,7 +457,7 @@ def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers:
451457
if not linked_labels:
452458
linked_labels = []
453459
seen_pointers.add(link_py_int)
454-
self._decode_labels_at_offset(link, linked_labels, seen_pointers)
460+
self._decode_labels_at_offset(link, linked_labels, seen_pointers, depth + 1)
455461
self._name_cache[link_py_int] = linked_labels
456462
labels.extend(linked_labels)
457463
if len(labels) > MAX_DNS_LABELS:

tests/test_protocol.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,28 @@ def test_label_compression_attack():
10351035
assert len(parsed.answers()) == 1
10361036

10371037

1038+
def test_dns_compression_pointer_chain_depth_attack() -> None:
1039+
"""Test our wire parser rejects deeply chained compression pointers without recursing."""
1040+
# Build a packet with one question whose name is a 1500-deep chain of forward
1041+
# compression pointers, ending in a root label. Each pointer is 2 bytes,
1042+
# so chain length easily exceeds CPython's default recursion limit.
1043+
header = b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00"
1044+
# Question at offset 12: pointer to offset 18 (past the question's type/class).
1045+
question_name = bytes([0xC0, 18])
1046+
question_type_class = b"\x00\x01\x00\x01"
1047+
chain_depth = 1500
1048+
chain = bytearray()
1049+
for i in range(chain_depth):
1050+
target = 18 + 2 * (i + 1)
1051+
chain.append(0xC0 | (target >> 8))
1052+
chain.append(target & 0xFF)
1053+
chain.append(0x00)
1054+
packet = header + question_name + question_type_class + bytes(chain)
1055+
parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353))
1056+
assert parsed.valid is False
1057+
assert parsed.questions == []
1058+
1059+
10381060
def test_dns_compression_loop_attack():
10391061
"""Test our wire parser does not loop forever when dns compression is in a loop."""
10401062
packet = (

0 commit comments

Comments
 (0)