Skip to content

Commit e2ee4dc

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 = 512` bounds the total distinct addrs with in-flight state. The cap leaves headroom for a legitimate burst (a LAN-wide power-resume / boot storm where many devices announce at once) while bounding worst-case memory at ~72 MB. 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 e2ee4dc

4 files changed

Lines changed: 189 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@
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; 512 leaves headroom for a
89+
# legitimate burst (LAN-wide power-resume / boot storm where many
90+
# devices announce at once) while bounding worst-case memory at
91+
# ~72 MB even when a peer floods with spoofed source IPs.
92+
_MAX_DEFERRED_ADDRS = 512
93+
8094
_DNS_PACKET_HEADER_LEN = 12
8195

8296
_MAX_MSG_TYPICAL = 1460 # unused

tests/test_core.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,148 @@ 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 _synthetic_source_ip(i: int) -> str:
844+
"""Distinct synthetic source IPs from the documentation ranges."""
845+
if i < 256:
846+
return f"203.0.113.{i}"
847+
if i < 512:
848+
return f"198.51.100.{i - 256}"
849+
return f"192.0.2.{i - 512}"
850+
851+
852+
def test_tc_bit_total_addrs_is_bounded():
853+
"""Distinct addrs with deferred state must not exceed ``_MAX_DEFERRED_ADDRS``."""
854+
zc = Zeroconf(interfaces=["127.0.0.1"])
855+
_wait_for_start(zc)
856+
type_ = "_addrbound._tcp.local."
857+
info = r.ServiceInfo(
858+
type_,
859+
f"a.{type_}",
860+
80,
861+
0,
862+
0,
863+
{"path": "/~paulsm/"},
864+
"ash-2.local.",
865+
addresses=[socket.inet_aton("10.0.1.2")],
866+
)
867+
zc.registry.async_add(info)
868+
869+
protocol = zc.engine.protocols[0]
870+
_clear_cache(zc)
871+
872+
raw = _make_distinct_tc_packets(1)[0]
873+
extra = 4
874+
addrs = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS + extra)]
875+
876+
# Run all enqueues in a single loop pass so PyPy / slow runners
877+
# cannot drain timers between calls.
878+
async def run() -> None:
879+
for source_ip in addrs:
880+
protocol.handle_query_or_defer(r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
881+
882+
assert zc.loop is not None
883+
asyncio.run_coroutine_threadsafe(run(), zc.loop).result()
884+
885+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
886+
assert len(protocol._timers) == const._MAX_DEFERRED_ADDRS
887+
888+
zc.registry.async_remove(info)
889+
zc.close()
890+
891+
892+
def test_tc_bit_eviction_drops_oldest_addr():
893+
"""Adding a new addr at capacity drops the oldest insertion."""
894+
zc = Zeroconf(interfaces=["127.0.0.1"])
895+
_wait_for_start(zc)
896+
type_ = "_evictoldest._tcp.local."
897+
info = r.ServiceInfo(
898+
type_,
899+
f"a.{type_}",
900+
80,
901+
0,
902+
0,
903+
{"path": "/~paulsm/"},
904+
"ash-2.local.",
905+
addresses=[socket.inet_aton("10.0.1.2")],
906+
)
907+
zc.registry.async_add(info)
908+
909+
protocol = zc.engine.protocols[0]
910+
_clear_cache(zc)
911+
912+
raw = _make_distinct_tc_packets(1)[0]
913+
fillers = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS)]
914+
new_addr = _synthetic_source_ip(const._MAX_DEFERRED_ADDRS)
915+
916+
async def run_fill() -> None:
917+
for source_ip in fillers:
918+
protocol.handle_query_or_defer(r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
919+
920+
async def run_overflow() -> None:
921+
protocol.handle_query_or_defer(r.DNSIncoming(raw), new_addr, const._MDNS_PORT, Mock(), ())
922+
923+
assert zc.loop is not None
924+
asyncio.run_coroutine_threadsafe(run_fill(), zc.loop).result()
925+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
926+
oldest = fillers[0]
927+
assert oldest in protocol._deferred
928+
929+
asyncio.run_coroutine_threadsafe(run_overflow(), zc.loop).result()
930+
assert oldest not in protocol._deferred
931+
assert oldest not in protocol._timers
932+
assert new_addr in protocol._deferred
933+
assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS
934+
935+
zc.registry.async_remove(info)
936+
zc.close()
937+
938+
797939
@pytest.mark.asyncio
798940
async def test_open_close_twice_from_async() -> None:
799941
"""Test we can close twice from a coroutine when using Zeroconf.

0 commit comments

Comments
 (0)