Skip to content

Commit da508d8

Browse files
committed
fix: bound QuestionHistory per-entry known-answer payload
`QuestionHistory._history[question] = (now, known_answers)` stores the incoming known-answers set by reference. The dict's entry count is capped at `_MAX_QUESTION_HISTORY_ENTRIES = 10000`, but each entry's set was unbounded. `QueryHandler.async_response` builds the set from the union of every TC-deferred fragment's answers — up to `_MAX_DEFERRED_PER_ADDR = 16` packets x ~750 records each — so a LAN peer sustaining TC-fragmented queries with large known-answer lists can pin hundreds of MB across the `_MAX_QUESTION_HISTORY_ENTRIES` dimension. The records never enter the DNS cache, so `_MAX_CACHE_RECORDS` does not bound this path. `add_question_at_time` now drops the insert when the known-answers set exceeds `_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY = 256` (well above any RFC-realistic single-question known-answer list). Truncating to a subset was considered and rejected: `suppresses()` returns True when the stored set is a subset of the incoming known-answers, so a smaller stored set matches more easily and would over-suppress legitimate follow-up queries — the conservative direction is to forgo suppression for the oversized query, not to retain a partial snapshot. Any pre-existing smaller entry for the same question key is left untouched. The new constant is `cdef unsigned int` in `_history.pxd` so the bound check compiles to a direct C integer compare.
1 parent b22c8ff commit da508d8

4 files changed

Lines changed: 89 additions & 1 deletion

File tree

src/zeroconf/_history.pxd

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

66
cdef cython.double _DUPLICATE_QUESTION_INTERVAL
77
cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES
8+
cdef unsigned int _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY
89

910
cdef class QuestionHistory:
1011

src/zeroconf/_history.py

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

2525
from ._dns import DNSQuestion, DNSRecord
26-
from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES
26+
from .const import (
27+
_DUPLICATE_QUESTION_INTERVAL,
28+
_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY,
29+
_MAX_QUESTION_HISTORY_ENTRIES,
30+
)
2731

2832
# The QuestionHistory is used to implement Duplicate Question Suppression
2933
# https://datatracker.ietf.org/doc/html/rfc6762#section-7.3
@@ -40,6 +44,15 @@ def __init__(self) -> None:
4044

4145
def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None:
4246
"""Remember a question with known answers."""
47+
if len(known_answers) > _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY:
48+
# Refuse to pin an attacker-sized known-answer payload.
49+
# Any pre-existing entry for this question stays in place
50+
# so legitimate suppression continues; the cost is missing
51+
# one round of suppression for this (likely malicious)
52+
# query. Truncating instead would over-suppress because
53+
# `suppresses()` matches when the stored set is a subset
54+
# of the incoming known-answers (smaller set, easier match).
55+
return
4356
if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES:
4457
self._evict_to_make_room(now)
4558
self._history[question] = (now, known_answers)

src/zeroconf/const.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@
7777
# flooding distinct questions (RFC 6762 §7.3, defense-in-depth).
7878
_MAX_QUESTION_HISTORY_ENTRIES = 10000
7979

80+
# Per-entry cap on the number of known-answer records QuestionHistory
81+
# will retain. Each TC-deferred reassembly can carry up to ~12k records
82+
# (~750 records/packet x _MAX_DEFERRED_PER_ADDR fragments), and the
83+
# resulting set is stored by reference under each non-unicast question
84+
# in the history dict; without a per-entry cap a LAN attacker can pin
85+
# hundreds of MB across the _MAX_QUESTION_HISTORY_ENTRIES dimension.
86+
# 256 is well above any RFC-realistic known-answer list for a single
87+
# question; oversized payloads are dropped from the history (no
88+
# suppression for that one query) rather than truncated, since a
89+
# truncated stored set would over-suppress legitimate follow-up
90+
# queries (`suppresses()` returns True when stored set is a subset of
91+
# the incoming known-answers, so a smaller stored set matches more
92+
# easily).
93+
_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY = 256
94+
8095
# Per-addr cap on the number of truncated (TC-bit) packets retained for
8196
# RFC 6762 §18.5 reassembly. The spec anticipates only a handful of
8297
# segments per truncated query; 16 is well above legitimate need and

tests/test_history.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,62 @@ def test_question_history_opportunistic_expire():
133133

134134
assert fresh in history._history
135135
assert len(history._history) == 1
136+
137+
138+
def _make_known_answers(count: int) -> set[r.DNSRecord]:
139+
"""Build a set of ``count`` distinct PTR records for use as known-answers."""
140+
return {
141+
r.DNSPointer(
142+
"_svc._tcp.local.",
143+
const._TYPE_PTR,
144+
const._CLASS_IN,
145+
10000,
146+
f"target{i}._svc._tcp.local.",
147+
)
148+
for i in range(count)
149+
}
150+
151+
152+
def test_question_history_oversized_known_answers_dropped():
153+
"""Known-answer sets above the per-entry cap are not stored."""
154+
history = QuestionHistory()
155+
now = r.current_time_millis()
156+
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
157+
158+
oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1)
159+
history.add_question_at_time(question, now, oversized)
160+
161+
assert question not in history._history
162+
163+
164+
def test_question_history_oversized_preserves_existing_entry():
165+
"""An oversized payload must not displace a pre-existing small entry."""
166+
history = QuestionHistory()
167+
now = r.current_time_millis()
168+
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
169+
170+
small = _make_known_answers(2)
171+
history.add_question_at_time(question, now, small)
172+
assert history.suppresses(question, now, small)
173+
174+
# An oversized follow-up must be ignored; the small entry stays and
175+
# continues to drive suppression.
176+
oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1)
177+
history.add_question_at_time(question, now, oversized)
178+
179+
stored_set = history._history[question][1]
180+
assert stored_set is small
181+
assert history.suppresses(question, now, small)
182+
183+
184+
def test_question_history_at_cap_known_answers_is_stored():
185+
"""A known-answer set exactly at the per-entry cap is retained."""
186+
history = QuestionHistory()
187+
now = r.current_time_millis()
188+
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
189+
190+
at_cap = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY)
191+
history.add_question_at_time(question, now, at_cap)
192+
193+
assert question in history._history
194+
assert history._history[question][1] is at_cap

0 commit comments

Comments
 (0)