Skip to content

Commit 1998e43

Browse files
committed
fix: bound QuestionHistory size to prevent unbounded LAN-driven growth
QuestionHistory._history was unbounded between the 10s cache-cleanup ticks. A LAN peer streaming distinct questions could grow the dict (and the retained known_answers sets) for ~10s before async_expire ran, easily reaching hundreds of MB at line-rate. Cap _history at _MAX_QUESTION_HISTORY_ENTRIES (10000). When the cap is hit on insert, first call async_expire(now) opportunistically to drop entries past _DUPLICATE_QUESTION_INTERVAL; if the dict is still full, evict the oldest insertion (dict is ordered) until there is room. Match the existing DNSCache cap pattern. Fixes #1723
1 parent 31194a3 commit 1998e43

4 files changed

Lines changed: 70 additions & 1 deletion

File tree

src/zeroconf/_history.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ from ._dns cimport DNSQuestion
44

55

66
cdef cython.double _DUPLICATE_QUESTION_INTERVAL
7+
cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES
78

89
cdef class QuestionHistory:
910

src/zeroconf/_history.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from __future__ import annotations
2424

2525
from ._dns import DNSQuestion, DNSRecord
26-
from .const import _DUPLICATE_QUESTION_INTERVAL
26+
from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES
2727

2828
# The QuestionHistory is used to implement Duplicate Question Suppression
2929
# https://datatracker.ietf.org/doc/html/rfc6762#section-7.3
@@ -40,6 +40,14 @@ def __init__(self) -> None:
4040

4141
def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None:
4242
"""Remember a question with known answers."""
43+
# Bound history size between the periodic 10s cleanup ticks. When at
44+
# cap, first drop entries past the duplicate-suppression window; if
45+
# still at cap, evict the oldest insertion (dict is ordered).
46+
if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES:
47+
self.async_expire(now)
48+
while len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES:
49+
oldest = next(iter(self._history))
50+
del self._history[oldest]
4351
self._history[question] = (now, known_answers)
4452

4553
def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> bool:

src/zeroconf/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@
6565
# to retain by multicasting many unique-name records.
6666
_MAX_CACHE_RECORDS = 10000
6767

68+
# Upper bound on the number of entries QuestionHistory will hold between
69+
# the periodic 10s cache-cleanup ticks. Bounds the memory a malicious LAN
70+
# peer can force the duplicate-question-suppression history to retain by
71+
# flooding distinct questions (RFC 6762 §7.3, defense-in-depth).
72+
_MAX_QUESTION_HISTORY_ENTRIES = 10000
73+
6874
_DNS_PACKET_HEADER_LEN = 12
6975

7076
_MAX_MSG_TYPICAL = 1460 # unused

tests/test_history.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,57 @@ def test_question_expire():
7878

7979
# Verify the question not longer suppressed since the cache has expired
8080
assert not history.suppresses(question, now, other_known_answers)
81+
82+
83+
def test_question_history_bounded():
84+
"""History keeps a hard cap so a LAN flood cannot grow it without bound."""
85+
history = QuestionHistory()
86+
now = r.current_time_millis()
87+
answers: set[r.DNSRecord] = set()
88+
89+
cap = const._MAX_QUESTION_HISTORY_ENTRIES
90+
for i in range(cap + 500):
91+
q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
92+
history.add_question_at_time(q, now, answers)
93+
94+
assert len(history._history) <= cap
95+
96+
97+
def test_question_history_evicts_oldest_first():
98+
"""When at cap, the oldest insertion is dropped first."""
99+
history = QuestionHistory()
100+
now = r.current_time_millis()
101+
answers: set[r.DNSRecord] = set()
102+
103+
cap = const._MAX_QUESTION_HISTORY_ENTRIES
104+
first = r.DNSQuestion("_first._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
105+
history.add_question_at_time(first, now, answers)
106+
107+
# Fill remaining slots with fresh, non-expired entries.
108+
for i in range(cap):
109+
q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
110+
history.add_question_at_time(q, now, answers)
111+
112+
assert first not in history._history
113+
assert len(history._history) <= cap
114+
115+
116+
def test_question_history_opportunistic_expire():
117+
"""Adding past the cap first drops expired entries before evicting fresh ones."""
118+
history = QuestionHistory()
119+
old = r.current_time_millis()
120+
answers: set[r.DNSRecord] = set()
121+
122+
cap = const._MAX_QUESTION_HISTORY_ENTRIES
123+
for i in range(cap):
124+
q = r.DNSQuestion(f"_stale{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
125+
history.add_question_at_time(q, old, answers)
126+
127+
# All prior entries are now stale (>999ms old). Adding one more should
128+
# trigger opportunistic expiry rather than evicting only the oldest one.
129+
fresh_now = old + const._DUPLICATE_QUESTION_INTERVAL + 1
130+
fresh = r.DNSQuestion("_fresh._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
131+
history.add_question_at_time(fresh, fresh_now, answers)
132+
133+
assert fresh in history._history
134+
assert len(history._history) == 1

0 commit comments

Comments
 (0)