fix: bound DNS compression-pointer chain depth in DNSIncoming#1719
Conversation
|
@bluetoothbot review |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1719 +/- ##
=======================================
Coverage 99.76% 99.76%
=======================================
Files 33 33
Lines 3426 3428 +2
Branches 471 472 +1
=======================================
+ Hits 3418 3420 +2
Misses 5 5
Partials 3 3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR hardens DNSIncoming against a denial-of-service vector where deeply chained DNS compression pointers can trigger excessive recursion (and uncaught RecursionError) during name decoding.
Changes:
- Add an explicit
depthcounter to_decode_labels_at_offsetto bound compression-pointer chain depth and raiseIncomingDecodeErrorwhen exceeded. - Add
RecursionErrortoDECODE_EXCEPTIONSto ensure unexpected recursion failures are contained as “invalid packet” handling. - Add a regression test that constructs a 1500-pointer forward-chain packet and asserts the parse is marked invalid without exceptions escaping.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
tests/test_protocol.py |
Adds regression coverage for a deep compression-pointer chain attack scenario. |
src/zeroconf/_protocol/incoming.py |
Implements pointer-chain depth limiting and broadens decode exception handling to include RecursionError. |
src/zeroconf/_protocol/incoming.pxd |
Updates the Cython signature to match the new _decode_labels_at_offset(..., depth) parameter. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Closes #1713. A crafted mDNS packet can chain thousands of forward compression pointers into a single ~3 kB datagram. ``DNSIncoming._decode_labels_at_offset`` recurses once per pointer it follows (RFC 1035 §4.1.4 name compression), so a deep chain blows through CPython's default recursion limit (``sys.getrecursionlimit() == 1000``). ``RecursionError`` was *not* in ``DECODE_EXCEPTIONS``, so it escaped ``DNSIncoming.__init__`` and bubbled up to asyncio's default exception handler — turning a single small multicast packet from any host on the local link (a guest on the same Wi-Fi, a compromised IoT device, a container on a shared bridge) into sustained CPU burn (each crash unwinds ~1000 frames + the asyncio exception machinery) and debug-log flooding. Home Assistant deployments on Raspberry-Pi-class hardware are the canonical victim. ``seen_pointers`` already blocked cycles and ``MAX_DNS_LABELS = 128`` already capped the *number of labels*, but nothing capped the *chain length of unique forward pointers*. A ``_MAX_MSG_ABSOLUTE`` (8966 B) packet can carry ~4000 2-byte pointers, each pointing to the next. Thread an explicit ``depth`` counter through ``_decode_labels_at_offset`` and raise ``IncomingDecodeError`` when it exceeds ``MAX_DNS_LABELS`` — same bound as the existing label cap, so no new constant. Belt-and- braces, add ``RecursionError`` to ``DECODE_EXCEPTIONS`` so any future regression is contained as an invalid packet rather than logged by asyncio's default handler. ``incoming.pxd`` updated in the same commit so the Cython build picks up the new ``unsigned int depth`` parameter. ``cython -a`` confirms the depth check and ``depth + 1`` recursive-call argument both compile to score-0 (pure C) — direct ``unsigned int`` compare and a C add — with no Python interaction added to the hot path. Function entry score is unchanged (score-11, same boilerplate). Wall-clock smoke test on a typical compressed mDNS response (best-of-10 over 50k iters, REQUIRE_CYTHON=1): 1237.2 ns/parse pre-fix vs 1234.1 ns/parse post-fix — within noise. CWE-674 (Uncontrolled Recursion). LAN-local attack surface only.
b35bea9 to
2e07f25
Compare
PR Review — fix: bound DNS compression-pointer chain depth in DNSIncomingClean, well-bounded security fix for CWE-674. The depth cap reuses MAX_DNS_LABELS (128) — a principled choice because legitimate names are bounded by MAX_NAME_LENGTH=253 bytes, which caps the label count well below 128, and pointer-follow depth in well-formed packets is ≤ label count. The check fires at function entry (line 425), so failed parses don't pollute Checklist
SummaryClean, well-bounded security fix for CWE-674. The depth cap reuses MAX_DNS_LABELS (128) — a principled choice because legitimate names are bounded by MAX_NAME_LENGTH=253 bytes, which caps the label count well below 128, and pointer-follow depth in well-formed packets is ≤ label count. The check fires at function entry (line 425), so failed parses don't pollute Automated review by Kōan2e07f25 |
Summary
Closes #1713. A crafted mDNS packet chaining thousands of forward DNS
name-compression pointers (RFC 1035 §4.1.4) into a single ~3 kB datagram
blows
DNSIncoming._decode_labels_at_offsetthrough CPython's defaultrecursion limit (
sys.getrecursionlimit() == 1000).RecursionErrorwas not in
DECODE_EXCEPTIONS, so it escapedDNSIncoming.__init__and bubbled up to asyncio's default exception handler — turning a single
small multicast packet from any host on the local link (a guest on the
same Wi-Fi, a compromised IoT device, a container on a shared bridge)
into sustained CPU burn and debug-log flooding. Home Assistant on
Raspberry-Pi-class hardware is the canonical victim. Adds a hard cap
on the pointer-chain depth, mirroring the existing label cap.
Details
seen_pointersalready blocked cycles andMAX_DNS_LABELS = 128already capped the number of labels, but nothing capped the chain
length of unique forward pointers. A
_MAX_MSG_ABSOLUTE(8966 B)packet can carry ~4000 2-byte pointers, each pointing to the next
unique offset.
_decode_labels_at_offsetgains adepth: unsigned intparameter(declared in
_protocol/incoming.pxd). Top-of-function check raisesIncomingDecodeErrorwhendepth > MAX_DNS_LABELS— the samebound as the existing label cap, so no new constant. The single
external caller (
_read_name) passes0; the recursive call sitepasses
depth + 1.MAX_DNS_LABELS(128) is plenty for any legitimatename — a 253-byte name (the RFC 6762 §16 / RFC 1035 §3.1 limit) caps
out at ~127 single-byte labels even with every label minimized. The
recursion depth is bounded by the number of pointer follows, which
in well-formed packets is ≤ the label count, so legitimate traffic
never approaches the cap.
RecursionErroris added toDECODE_EXCEPTIONSso any future regression that lets the recursion run away is contained
as an invalid packet rather than logged by asyncio's default handler.
recursion-DoS class on its own; quotas would be a separate change to
the listener.
Cython perf check
cython -aon the touched_protocol/incoming.pyshows every newline as score-0 (no Python interpreter overhead):
Generated C compiles
depth > MAX_DNS_LABELSto a directunsigned int > unsigned intcompare,depth + 1to a C add,and the recursive call to the existing C-level vtable dispatch with
one extra
unsigned intargument. Function entry score is unchanged(score-11, same boilerplate). Wall-clock smoke test on a typical
compressed mDNS response (best-of-10 over 50k iters,
REQUIRE_CYTHON=1):Within noise — the per-call overhead is one C compare at function
entry and one C add per pointer follow.
Test plan
SKIP_CYTHON=1 poetry run pytest tests/test_protocol.py—47 passed (46 pre-existing + 1 new regression below). Coverage
report shows 98 % on
_protocol/incoming.pywith the 5uncovered lines (143, 199–200, 210, 213) all pre-existing and
unrelated to this PR — every line touched by this diff is hit.
REQUIRE_CYTHON=1 poetry install --only=main,devrebuildsthe extension against the new
.pxd; full suitepoetry run pytest --timeout=60— 338 passed, 3 skipped.poetry run pre-commit run --files <changed files>— allhooks pass (ruff lint + format, mypy, flake8, codespell,
cython-lint, pyupgrade).
tests/test_protocol.py::test_dns_compression_pointer_chain_depth_attackfeeds a 1500-pointer forward chain through
DNSIncomingandasserts
valid is Falsewith no exception escaping. Verifiedto fail on pre-fix
masterwithRecursionError: maximum recursion depth exceeded.assigned alongside the patched release.