Skip to content

Cached responses replay the first client's EDNS Client Subnet to every later client - same class as #242 (EDNS Cookie), breaks forwarders that validate ECS (dnsmasq add-subnet), ~20% of queries discarded #324

Description

@jtklinger

Summary

With cache_enable = true, ctrld stores the upstream reply with the querying
client's EDNS Client Subnet (ECS, EDNS option 8) still embedded
, then replays
that option verbatim to every subsequent client that hits the cache entry until
TTL expiry.

Any downstream forwarder that validates the echoed ECS per RFC 7871 §9.2 —
dnsmasq with add-subnet does — discards every replayed reply whose subnet
doesn't match what it sent
(discarding DNS reply: subnet option mismatch).
The client gets no answer at all; retries receive the same stale option and do
not self-heal until the cache entry expires.

Two asymmetries make this worse than a warning:

  • ctrld only echoes ECS for IPv6 client subnets; IPv4-subnet cache misses
    return no ECS. So an IPv4 client handed a cached-from-IPv6 entry receives an
    IPv6 subnet it can never match.
  • Whichever client populates an entry first decides its fate: IPv4-first →
    clean for everyone until TTL; IPv6-first → poisoned for every other client.

This is the same cache behavior as #242 ("Bad EDNS Cookie with Cached
Responses", fixed for option COOKIE in v1.4.4): the cached answer is copied
without rewriting/stripping the per-client EDNS option. ECS needs the same
treatment.

Environment

  • ctrld v1.5.4 (also reproduced identically on v1.5.1 before upgrading)
  • UniFi Dream Machine Pro (UniFi OS), installed via the ControlD router installer
  • dnsmasq forwards to ctrld on 127.0.0.1:5354 with add-subnet=32,128
    (this is the stock ctrld UniFi integration — /run/dnsmasq.dhcp.conf.d/zzzctrld.conf)
  • Listener policy routes ~11 networks (IPv4 CIDRs + IPv6 /64s) to per-network
    DoH upstreams (https://dns.controld.com/<resolver-id>)
  • ControlD-side ECS is unset on all profiles (confirmed via API) — the
    echoed option is synthesized locally by ctrld

Reproduction (4 queries, one fresh name)

Fresh, never-cached names via nip.io; dig @127.0.0.1 -p 5354 +subnet=<s> <name> A.
Output below from v1.5.4 (client subnets anonymized to the 2001:db8 documentation prefix):

# 1. IPv6 "client A" populates the cache — MISS, echoes its own subnet (OK)
$ dig +subnet=2001:db8:0:20::1111/128 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0        Query time: 80 msec

# 2. IPv6 "client B", different subnet, same name — HIT, gets A's subnet (BUG)
$ dig +subnet=2001:db8:0:20::2222/128 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0        Query time: 10 msec

# 3. IPv4 client, same name — HIT, gets A's IPv6 subnet (BUG, can never match)
$ dig +subnet=192.168.20.77/32 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0        Query time: 10 msec

# 4. IPv4 control, second fresh name — MISS, no ECS echoed (OK; dnsmasq accepts)
$ dig +subnet=192.168.20.77/32 fresh2.10.9.8.7.nip.io A
;; (no CLIENT-SUBNET in reply)                     Query time: 740 msec

Steps 2 and 3 are the bug. When the client is dnsmasq (rather than dig), RFC
7871 §9.2 validation rejects both: dnsmasq logs
discarding DNS reply: subnet option mismatch and returns nothing to the
querying client.

Expected behavior

Cached replies should either strip the stored ECS option or rewrite it to
the current client's subnet — exactly as the v1.4.4 fix for #242 did for EDNS
COOKIE. A reply carrying another client's (wrong-family) subnet violates the
echo expectation that RFC 7871 validators enforce.

Measured impact

On a home network (~23 networks, ~3–5 q/s through dnsmasq → ctrld):

  • 19.6% of all queries discarded (journald discard count ÷ tcpdump query
    count, synchronized 5-min windows); independently corroborated at 20.5%
    by wire-capture ECS-mismatch counting (484 paired query/replies), and up to
    28.3% in evening windows.
  • IPv4 clients matched 0% of the time when handed any replayed ECS.
  • Controlled toggle: cache_enable on → 90 discards / off → 0 / on → 86
    (per equal window).
  • Ran for weeks looking like "intermittent slow DNS", since whichever protocol
    family populated a name first decided whether that name worked.

Workaround

cache_enable = false — discards drop to zero immediately (verified, plus the
4-query probe above then shows every reply carrying the client's own subnet or
none). Costs the cache-hit latency, so we'd love to re-enable once fixed.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions