From 321d8f6b4c26f49fa6c08bcd81e33746770955a7 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Mon, 7 Sep 2026 12:39:44 -0700 Subject: [PATCH 1/2] fix: cap TTL to 10s in legacy unicast responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: RFC 6762 §6.7 requires the records in a legacy unicast response to carry a TTL no greater than 10 seconds, since legacy resolvers cache them without the mDNS cache-coherency mechanisms. Responses went out with the records' true TTLs (120s for host records, 4500s for PTR/TXT). Fix: Fold the TTL cap into the existing per-record pass that clears cache-flush bits, renaming clear_cache_flush_bits() to update_records_for_legacy_unicast(). Add the LEGACY_UNICAST_MAX_TTL constant and assert the cap in test_legacy_unicast_response. Co-Authored-By: Claude Opus 4.8 --- src/dns_parser.rs | 30 ++++++++++++++++++++++-------- src/service_daemon.rs | 18 ++++++++++++++---- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/dns_parser.rs b/src/dns_parser.rs index 87830df9..055f915a 100644 --- a/src/dns_parser.rs +++ b/src/dns_parser.rs @@ -282,6 +282,11 @@ pub const CLASS_MASK: u16 = 0x7FFF; /// Cache-flush bit: the most significant bit of the rrclass field of the resource record. pub const CLASS_CACHE_FLUSH: u16 = 0x8000; +/// RFC 6762 §6.7: the records in a legacy unicast response MUST NOT carry a TTL +/// greater than 10 seconds. Legacy resolvers cache them without the mDNS +/// cache-coherency mechanisms, so a long TTL would leave them stale for minutes. +pub const LEGACY_UNICAST_MAX_TTL: u32 = 10; + /// Absolute max size of UDP datagram payload for an mDNS packet over IPv4. /// /// RFC 6762 section 17: @@ -2041,19 +2046,28 @@ impl DnsOutgoing { self.questions.push(q); } - /// Clear the cache-flush (unique) bit on every answer and additional - /// record. Required for RFC 6762 §6.7 (Legacy Unicast Responses) and - /// §10.2 — a legacy resolver doesn't know about the cache-flush bit - /// and may misinterpret responses where it is set. - pub fn clear_cache_flush_bits(&mut self) { + /// Adjust every answer, additional, and authority record so the message is + /// a valid RFC 6762 §6.7 legacy unicast response: + /// + /// - Clear the cache-flush (unique) bit (§6.7 and §10.2): a legacy resolver + /// doesn't know about it and may misinterpret responses where it is set. + /// - Cap the TTL at [`LEGACY_UNICAST_MAX_TTL`] seconds: legacy resolvers + /// cache records without the mDNS cache-coherency mechanisms, so the true + /// (longer) TTL must not leak out to them. + pub fn update_records_for_legacy_unicast(&mut self) { + let update = |rec: &mut DnsRecordBox| { + let record = rec.get_record_mut(); + record.entry.cache_flush = false; + record.ttl = record.ttl.min(LEGACY_UNICAST_MAX_TTL); + }; for (rec, _) in &mut self.answers { - rec.get_record_mut().entry.cache_flush = false; + update(rec); } for rec in &mut self.additionals { - rec.get_record_mut().entry.cache_flush = false; + update(rec); } for rec in &mut self.authorities { - rec.get_record_mut().entry.cache_flush = false; + update(rec); } } diff --git a/src/service_daemon.rs b/src/service_daemon.rs index ecdc3627..e84e1c81 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -3550,8 +3550,9 @@ impl Zeroconf { // getaddrinfo, iOS resolver fallback). The response MUST be unicast // back to the querier's source IP and port; multicast replies will // never reach the querier's ephemeral socket. Legacy unicast - // responses must also echo the question section and clear the - // cache-flush bit, since legacy resolvers don't understand it. + // responses must also echo the question section, clear the + // cache-flush bit (legacy resolvers don't understand it), and cap + // record TTLs to 10 seconds (see update_records_for_legacy_unicast). let unicast_dest = if querier_addr.port() != MDNS_PORT { Some(querier_addr) } else { @@ -3562,7 +3563,7 @@ impl Zeroconf { for q in msg.questions() { out.add_question(q.entry_name(), q.entry_type()); } - out.clear_cache_flush_bits(); + out.update_records_for_legacy_unicast(); out.set_multicast(false); } else if msg.num_authorities() == 0 { // RFC 6762 §6: a record MUST NOT be multicast on an interface @@ -5235,7 +5236,7 @@ mod tests { use crate::{ dns_parser::{ DnsEntryExt, DnsIncoming, DnsOutgoing, DnsPointer, InterfaceId, RRType, ScopedIp, - CLASS_IN, FLAGS_AA, FLAGS_QR_QUERY, FLAGS_QR_RESPONSE, + CLASS_IN, FLAGS_AA, FLAGS_QR_QUERY, FLAGS_QR_RESPONSE, LEGACY_UNICAST_MAX_TTL, }, service_daemon::{add_answer_of_service, check_hostname}, }; @@ -5576,6 +5577,15 @@ mod tests { "legacy unicast responses must clear the cache-flush bit" ); + // RFC 6762 §6.7: the TTL must be capped at 10 seconds, even though the + // A record's true host TTL (DNS_HOST_TTL) is 120 seconds. + assert!( + answer.get_record().get_ttl() <= LEGACY_UNICAST_MAX_TTL, + "legacy unicast response TTL {} exceeds the {}s cap", + answer.get_record().get_ttl(), + LEGACY_UNICAST_MAX_TTL + ); + daemon.shutdown().unwrap(); } From da53fbc6d9e4e8ba6d3faa5eb3859fd94b9150a1 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Mon, 7 Sep 2026 14:03:05 -0700 Subject: [PATCH 2/2] update comments --- src/dns_parser.rs | 11 +++++------ src/service_daemon.rs | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/dns_parser.rs b/src/dns_parser.rs index 055f915a..7acf4e6a 100644 --- a/src/dns_parser.rs +++ b/src/dns_parser.rs @@ -282,9 +282,8 @@ pub const CLASS_MASK: u16 = 0x7FFF; /// Cache-flush bit: the most significant bit of the rrclass field of the resource record. pub const CLASS_CACHE_FLUSH: u16 = 0x8000; -/// RFC 6762 §6.7: the records in a legacy unicast response MUST NOT carry a TTL -/// greater than 10 seconds. Legacy resolvers cache them without the mDNS -/// cache-coherency mechanisms, so a long TTL would leave them stale for minutes. +/// RFC 6762 §6.7: The resource record TTL given in a legacy unicast response SHOULD NOT +/// be greater than ten seconds. pub const LEGACY_UNICAST_MAX_TTL: u32 = 10; /// Absolute max size of UDP datagram payload for an mDNS packet over IPv4. @@ -2046,14 +2045,14 @@ impl DnsOutgoing { self.questions.push(q); } - /// Adjust every answer, additional, and authority record so the message is - /// a valid RFC 6762 §6.7 legacy unicast response: + /// Adjust records so the message is a valid legacy unicast response: /// - /// - Clear the cache-flush (unique) bit (§6.7 and §10.2): a legacy resolver + /// - Clear the cache-flush (unique) bit: a legacy resolver /// doesn't know about it and may misinterpret responses where it is set. /// - Cap the TTL at [`LEGACY_UNICAST_MAX_TTL`] seconds: legacy resolvers /// cache records without the mDNS cache-coherency mechanisms, so the true /// (longer) TTL must not leak out to them. + /// Refer to [RFC 6762 Section 6.7] for details. pub fn update_records_for_legacy_unicast(&mut self) { let update = |rec: &mut DnsRecordBox| { let record = rec.get_record_mut(); diff --git a/src/service_daemon.rs b/src/service_daemon.rs index e84e1c81..31b553e8 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -5577,8 +5577,6 @@ mod tests { "legacy unicast responses must clear the cache-flush bit" ); - // RFC 6762 §6.7: the TTL must be capped at 10 seconds, even though the - // A record's true host TTL (DNS_HOST_TTL) is 120 seconds. assert!( answer.get_record().get_ttl() <= LEGACY_UNICAST_MAX_TTL, "legacy unicast response TTL {} exceeds the {}s cap",