Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/zeroconf/_history.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ from ._dns cimport DNSQuestion

cdef cython.double _DUPLICATE_QUESTION_INTERVAL
cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES
cdef unsigned int _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY

cdef class QuestionHistory:

Expand Down
15 changes: 14 additions & 1 deletion src/zeroconf/_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
from __future__ import annotations

from ._dns import DNSQuestion, DNSRecord
from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES
from .const import (
_DUPLICATE_QUESTION_INTERVAL,
_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY,
_MAX_QUESTION_HISTORY_ENTRIES,
)

# The QuestionHistory is used to implement Duplicate Question Suppression
# https://datatracker.ietf.org/doc/html/rfc6762#section-7.3
Expand All @@ -40,6 +44,15 @@ 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 len(known_answers) > _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY:
# Refuse to pin an attacker-sized known-answer payload.
# Any pre-existing entry for this question stays in place
# so legitimate suppression continues; the cost is missing
# one round of suppression for this (likely malicious)
# query. Truncating instead would over-suppress because
# `suppresses()` matches when the stored set is a subset
# of the incoming known-answers (smaller set, easier match).
return
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)
Expand Down
15 changes: 15 additions & 0 deletions src/zeroconf/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@
# flooding distinct questions (RFC 6762 §7.3, defense-in-depth).
_MAX_QUESTION_HISTORY_ENTRIES = 10000

# Per-entry cap on the number of known-answer records QuestionHistory
# will retain. Each TC-deferred reassembly can carry up to ~12k records
# (~750 records/packet x _MAX_DEFERRED_PER_ADDR fragments), and the
# resulting set is stored by reference under each non-unicast question
# in the history dict; without a per-entry cap a LAN attacker can pin
# hundreds of MB across the _MAX_QUESTION_HISTORY_ENTRIES dimension.
# 256 is well above any RFC-realistic known-answer list for a single
# question; oversized payloads are dropped from the history (no
# suppression for that one query) rather than truncated, since a
# truncated stored set would over-suppress legitimate follow-up
# queries (`suppresses()` returns True when stored set is a subset of
# the incoming known-answers, so a smaller stored set matches more
# easily).
_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY = 256

# Per-addr cap on the number of truncated (TC-bit) packets retained for
# RFC 6762 §18.5 reassembly. The spec anticipates only a handful of
# segments per truncated query; 16 is well above legitimate need and
Expand Down
59 changes: 59 additions & 0 deletions tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,62 @@ def test_question_history_opportunistic_expire():

assert fresh in history._history
assert len(history._history) == 1


def _make_known_answers(count: int) -> set[r.DNSRecord]:
"""Build a set of ``count`` distinct PTR records for use as known-answers."""
return {
r.DNSPointer(
"_svc._tcp.local.",
const._TYPE_PTR,
const._CLASS_IN,
10000,
f"target{i}._svc._tcp.local.",
)
for i in range(count)
}


def test_question_history_oversized_known_answers_dropped():
"""Known-answer sets above the per-entry cap are not stored."""
history = QuestionHistory()
now = r.current_time_millis()
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)

oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1)
history.add_question_at_time(question, now, oversized)

assert question not in history._history


def test_question_history_oversized_preserves_existing_entry():
"""An oversized payload must not displace a pre-existing small entry."""
history = QuestionHistory()
now = r.current_time_millis()
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)

small = _make_known_answers(2)
history.add_question_at_time(question, now, small)
assert history.suppresses(question, now, small)

# An oversized follow-up must be ignored; the small entry stays and
# continues to drive suppression.
oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1)
history.add_question_at_time(question, now, oversized)

stored_set = history._history[question][1]
assert stored_set is small
assert history.suppresses(question, now, small)


def test_question_history_at_cap_known_answers_is_stored():
"""A known-answer set exactly at the per-entry cap is retained."""
history = QuestionHistory()
now = r.current_time_millis()
question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN)

at_cap = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY)
history.add_question_at_time(question, now, at_cap)

assert question in history._history
assert history._history[question][1] is at_cap
Loading