From 0e5e637172ab7991e8e1f13be7e4e5d228ce8b8b Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Tue, 19 May 2026 17:05:55 -0700 Subject: [PATCH 1/2] fix: bound QuestionHistory size to prevent LAN-driven OOM (#1733) --- src/zeroconf/_history.pxd | 6 ++++- src/zeroconf/_history.py | 20 +++++++++++++- src/zeroconf/const.py | 6 +++++ tests/test_history.py | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index d1bb7baf..3105f592 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -4,13 +4,17 @@ from ._dns cimport DNSQuestion cdef cython.double _DUPLICATE_QUESTION_INTERVAL +cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES cdef class QuestionHistory: - cdef cython.dict _history + cdef public cython.dict _history cpdef void add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers) + @cython.locals(oldest=DNSQuestion, oldest_entry=cython.tuple, oldest_than=double) + cdef void _evict_to_make_room(self, double now) + @cython.locals(than=double, previous_question=cython.tuple, previous_known_answers=cython.set) cpdef bint suppresses(self, DNSQuestion question, double now, cython.set known_answers) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index 1b6f3fad..2a8274ee 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -23,7 +23,7 @@ from __future__ import annotations from ._dns import DNSQuestion, DNSRecord -from .const import _DUPLICATE_QUESTION_INTERVAL +from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES # The QuestionHistory is used to implement Duplicate Question Suppression # https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 @@ -40,6 +40,8 @@ def __init__(self) -> None: def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None: """Remember a question with known answers.""" + if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES: + self._evict_to_make_room(now) self._history[question] = (now, known_answers) def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> bool: @@ -75,3 +77,19 @@ def async_expire(self, now: _float) -> None: def clear(self) -> None: """Clear the history.""" self._history.clear() + + def _evict_to_make_room(self, now: _float) -> None: + """Drop expired or oldest entries when the history is at cap. + + Peeks at the oldest insertion (dict is ordered) โ€” only runs the + full O(n) async_expire sweep if it could actually reclaim + something, else a sustained flood at cap turns each insert into + a wasted scan. Falls back to oldest-first eviction. + """ + oldest = next(iter(self._history)) + oldest_entry = self._history[oldest] + oldest_than = oldest_entry[0] + if now - oldest_than > _DUPLICATE_QUESTION_INTERVAL: + self.async_expire(now) + while len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES: + del self._history[next(iter(self._history))] diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index a17e4685..595d8021 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -65,6 +65,12 @@ # to retain by multicasting many unique-name records. _MAX_CACHE_RECORDS = 10000 +# Upper bound on the number of entries QuestionHistory will hold between +# the periodic 10s cache-cleanup ticks. Bounds the memory a malicious LAN +# peer can force the duplicate-question-suppression history to retain by +# flooding distinct questions (RFC 6762 ยง7.3, defense-in-depth). +_MAX_QUESTION_HISTORY_ENTRIES = 10000 + _DNS_PACKET_HEADER_LEN = 12 _MAX_MSG_TYPICAL = 1460 # unused diff --git a/tests/test_history.py b/tests/test_history.py index e9254168..71743eba 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -78,3 +78,58 @@ def test_question_expire(): # Verify the question not longer suppressed since the cache has expired assert not history.suppresses(question, now, other_known_answers) + + +def test_question_history_bounded(): + """History keeps a hard cap so a LAN flood cannot grow it without bound.""" + history = QuestionHistory() + now = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + for i in range(cap + 500): + q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, now, answers) + + assert len(history._history) <= cap + + +def test_question_history_evicts_oldest_first(): + """When at cap, the oldest insertion is dropped first.""" + history = QuestionHistory() + now = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + first = r.DNSQuestion("_first._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(first, now, answers) + + # Add `cap` more fresh, non-expired entries โ€” one past the cap โ€” so the + # final insertion forces oldest-first eviction of `first`. + for i in range(cap): + q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, now, answers) + + assert first not in history._history + assert len(history._history) <= cap + + +def test_question_history_opportunistic_expire(): + """Adding past the cap first drops expired entries before evicting fresh ones.""" + history = QuestionHistory() + old = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + for i in range(cap): + q = r.DNSQuestion(f"_stale{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, old, answers) + + # All prior entries are now stale (>999ms old). Adding one more should + # trigger opportunistic expiry rather than evicting only the oldest one. + fresh_now = old + const._DUPLICATE_QUESTION_INTERVAL + 1 + fresh = r.DNSQuestion("_fresh._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(fresh, fresh_now, answers) + + assert fresh in history._history + assert len(history._history) == 1 From 7f0c476bf202b794a961986fc26bce008d8e86b2 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 00:12:15 +0000 Subject: [PATCH 2/2] 0.149.9 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc14f70d..c92f3e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.9 (2026-05-20) + +### Bug Fixes + +- Bound QuestionHistory size to prevent LAN-driven OOM + ([#1733](https://github.com/python-zeroconf/python-zeroconf/pull/1733), + [`0e5e637`](https://github.com/python-zeroconf/python-zeroconf/commit/0e5e637172ab7991e8e1f13be7e4e5d228ce8b8b)) + + ## v0.149.8 (2026-05-19) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 82aca1dd..0cfcd1ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.8" +version = "0.149.9" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a9d8b350..37e4a727 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.8" +__version__ = "0.149.9" __license__ = "LGPL"