Skip to content

Commit eb031b7

Browse files
committed
fix: bound TC-deferred queues against spoofed-source flood OOM
`AsyncListener.handle_query_or_defer` retains every truncated query in `self._deferred[addr]` and arms a per-addr timer that flushes within ~500ms. Neither the per-addr list nor the number of distinct addr keys was capped, so a LAN peer streaming byte-distinct TC-flagged queries — trivially amplified by spoofing source IPs — could grow `_deferred` and `_timers` until the process OOMs. Add two caps and an LRU-style eviction: - `_MAX_DEFERRED_PER_ADDR = 16` bounds the per-addr queue. RFC 6762 §18.5 anticipates only a handful of segments per truncated query. The existing O(N) dedup scan now runs over a constant-bounded list. - `_MAX_DEFERRED_ADDRS = 256` bounds the total distinct addrs with in-flight state. When a new addr would exceed the cap, the oldest (insertion-order) entry is evicted: its timer is cancelled and its queue is discarded so the bound holds even when an attacker rotates source IPs faster than the reassembly timer can drain entries. Both constants are cdef'd in `_listener.pxd` so the bound checks compile to direct C integer compares.
1 parent 8c9d6ce commit eb031b7

4 files changed

Lines changed: 167 additions & 1 deletion

File tree

src/zeroconf/_listener.pxd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ cdef bint TYPE_CHECKING
1515
cdef cython.uint _MAX_MSG_ABSOLUTE
1616
cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL
1717
cdef cython.uint _RECENT_PACKETS_MAX
18+
cdef cython.uint _MAX_DEFERRED_ADDRS
19+
cdef cython.uint _MAX_DEFERRED_PER_ADDR
1820

1921

2022
cdef class AsyncListener:
@@ -41,6 +43,8 @@ cdef class AsyncListener:
4143

4244
cdef _cancel_any_timers_for_addr(self, object addr)
4345

46+
cdef _evict_oldest_deferred(self)
47+
4448
@cython.locals(deadline=object, fire_at=double)
4549
cdef double _compute_deferred_fire_at(self, object addr, double now, double delay)
4650

src/zeroconf/_listener.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@
3232
from ._protocol.incoming import DNSIncoming
3333
from ._transport import _WrappedTransport, make_wrapped_transport
3434
from ._utils.time import current_time_millis, millis_to_seconds
35-
from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE, _RECENT_PACKETS_MAX
35+
from .const import (
36+
_DUPLICATE_PACKET_SUPPRESSION_INTERVAL,
37+
_MAX_DEFERRED_ADDRS,
38+
_MAX_DEFERRED_PER_ADDR,
39+
_MAX_MSG_ABSOLUTE,
40+
_RECENT_PACKETS_MAX,
41+
)
3642

3743
if TYPE_CHECKING:
3844
from ._core import Zeroconf
@@ -240,7 +246,17 @@ def handle_query_or_defer(
240246
self._respond_query(msg, addr, port, transport, v6_flow_scope)
241247
return
242248

249+
if addr not in self._deferred and len(self._deferred) >= _MAX_DEFERRED_ADDRS:
250+
# Bound total deferred addrs so a spoofed-source flood
251+
# cannot keep adding distinct entries; evict the oldest
252+
# (insertion-order) entry and discard its in-flight queue.
253+
self._evict_oldest_deferred()
254+
243255
deferred = self._deferred.setdefault(addr, [])
256+
if len(deferred) >= _MAX_DEFERRED_PER_ADDR:
257+
# Bound per-addr queue length; further fragments from the
258+
# same source are dropped until the timer flushes.
259+
return
244260
# If we get the same packet we ignore it
245261
for incoming in reversed(deferred):
246262
if incoming.data == msg.data:
@@ -293,6 +309,18 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None:
293309
if addr in self._timers:
294310
self._timers.pop(addr).cancel()
295311

312+
def _evict_oldest_deferred(self) -> None:
313+
"""Discard the oldest deferred addr's reassembly state.
314+
315+
Used when ``_MAX_DEFERRED_ADDRS`` would be exceeded; the
316+
evicted addr's queue and timer are dropped without firing, so
317+
the bound holds even when an attacker rotates source IPs.
318+
"""
319+
oldest_addr = next(iter(self._deferred))
320+
self._cancel_any_timers_for_addr(oldest_addr)
321+
self._deferred_deadlines.pop(oldest_addr, None)
322+
del self._deferred[oldest_addr]
323+
296324
def _respond_query(
297325
self,
298326
msg: DNSIncoming | None,

src/zeroconf/const.py

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

80+
# Per-addr cap on the number of truncated (TC-bit) packets retained for
81+
# RFC 6762 §18.5 reassembly. The spec anticipates only a handful of
82+
# segments per truncated query; 16 is well above legitimate need and
83+
# keeps the per-arrival dedup scan a constant-time cost under a flood.
84+
_MAX_DEFERRED_PER_ADDR = 16
85+
86+
# Per-listener cap on the number of distinct addrs with in-flight
87+
# TC-deferral state. Each entry can hold up to _MAX_DEFERRED_PER_ADDR
88+
# packets of up to _MAX_MSG_ABSOLUTE bytes, so 256 bounds worst-case
89+
# memory at ~36 MB even when a peer floods with spoofed source IPs.
90+
_MAX_DEFERRED_ADDRS = 256
91+
8092
_DNS_PACKET_HEADER_LEN = 12
8193

8294
_MAX_MSG_TYPICAL = 1460 # unused

tests/test_core.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,128 @@ def test_tc_bit_defer_window_is_bounded():
794794
zc.close()
795795

796796

797+
def _make_distinct_tc_packets(count: int, name_prefix: str = "q") -> list[bytes]:
798+
"""Generate ``count`` byte-distinct TC-flagged query packets for flood inputs."""
799+
packets = []
800+
for i in range(count):
801+
out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_TC)
802+
out.add_question(r.DNSQuestion(f"{name_prefix}{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN))
803+
packets.append(out.packets()[0])
804+
return packets
805+
806+
807+
def test_tc_bit_per_addr_queue_is_bounded():
808+
"""Per-addr deferred queue must not grow past ``_MAX_DEFERRED_PER_ADDR``."""
809+
zc = Zeroconf(interfaces=["127.0.0.1"])
810+
_wait_for_start(zc)
811+
type_ = "_perbound._tcp.local."
812+
info = r.ServiceInfo(
813+
type_,
814+
f"a.{type_}",
815+
80,
816+
0,
817+
0,
818+
{"path": "/~paulsm/"},
819+
"ash-2.local.",
820+
addresses=[socket.inet_aton("10.0.1.2")],
821+
)
822+
zc.registry.async_add(info)
823+
824+
protocol = zc.engine.protocols[0]
825+
_clear_cache(zc)
826+
source_ip = "203.0.113.21"
827+
828+
extra = 4
829+
packets = _make_distinct_tc_packets(const._MAX_DEFERRED_PER_ADDR + extra)
830+
for raw in packets:
831+
threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
832+
833+
assert len(protocol._deferred[source_ip]) == const._MAX_DEFERRED_PER_ADDR
834+
# Last ``extra`` packets must have been dropped, not displaced; the
835+
# earlier ``_MAX_DEFERRED_PER_ADDR`` entries are the ones retained.
836+
retained = [incoming.data for incoming in protocol._deferred[source_ip]]
837+
assert retained == packets[: const._MAX_DEFERRED_PER_ADDR]
838+
839+
zc.registry.async_remove(info)
840+
zc.close()
841+
842+
843+
def test_tc_bit_total_addrs_is_bounded():
844+
"""Distinct addrs with deferred state must not exceed ``_MAX_DEFERRED_ADDRS``."""
845+
zc = Zeroconf(interfaces=["127.0.0.1"])
846+
_wait_for_start(zc)
847+
type_ = "_addrbound._tcp.local."
848+
info = r.ServiceInfo(
849+
type_,
850+
f"a.{type_}",
851+
80,
852+
0,
853+
0,
854+
{"path": "/~paulsm/"},
855+
"ash-2.local.",
856+
addresses=[socket.inet_aton("10.0.1.2")],
857+
)
858+
zc.registry.async_add(info)
859+
860+
protocol = zc.engine.protocols[0]
861+
_clear_cache(zc)
862+
863+
raw = _make_distinct_tc_packets(1)[0]
864+
extra = 4
865+
for i in range(const._MAX_DEFERRED_ADDRS + extra):
866+
# Synthetic source IPs from the documentation range; each is
867+
# treated as a distinct addr key.
868+
source_ip = f"203.0.113.{i % 256}" if i < 256 else f"198.51.100.{i - 256}"
869+
threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
870+
871+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
872+
assert len(protocol._timers) == const._MAX_DEFERRED_ADDRS
873+
874+
zc.registry.async_remove(info)
875+
zc.close()
876+
877+
878+
def test_tc_bit_eviction_drops_oldest_addr():
879+
"""Adding a new addr at capacity drops the oldest insertion."""
880+
zc = Zeroconf(interfaces=["127.0.0.1"])
881+
_wait_for_start(zc)
882+
type_ = "_evictoldest._tcp.local."
883+
info = r.ServiceInfo(
884+
type_,
885+
f"a.{type_}",
886+
80,
887+
0,
888+
0,
889+
{"path": "/~paulsm/"},
890+
"ash-2.local.",
891+
addresses=[socket.inet_aton("10.0.1.2")],
892+
)
893+
zc.registry.async_add(info)
894+
895+
protocol = zc.engine.protocols[0]
896+
_clear_cache(zc)
897+
898+
raw = _make_distinct_tc_packets(1)[0]
899+
# Fill the dict to exactly capacity with predictable addrs.
900+
fillers = [f"203.0.113.{i // 256}.{i % 256}" for i in range(const._MAX_DEFERRED_ADDRS)]
901+
for source_ip in fillers:
902+
threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
903+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
904+
oldest = fillers[0]
905+
assert oldest in protocol._deferred
906+
907+
# One more distinct addr should evict the oldest insertion-order entry.
908+
new_addr = "198.51.100.1"
909+
threadsafe_query(zc, protocol, r.DNSIncoming(raw), new_addr, const._MDNS_PORT, Mock(), ())
910+
assert oldest not in protocol._deferred
911+
assert oldest not in protocol._timers
912+
assert new_addr in protocol._deferred
913+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
914+
915+
zc.registry.async_remove(info)
916+
zc.close()
917+
918+
797919
@pytest.mark.asyncio
798920
async def test_open_close_twice_from_async() -> None:
799921
"""Test we can close twice from a coroutine when using Zeroconf.

0 commit comments

Comments
 (0)