Coalesce identical misses, refuse non-QUERY opcodes, refresh hot entries early, check the answered question

Concurrent cache misses for one name each went upstream. After a restart, a
cache flush or a cluster-object change that is a burst of identical queries
exactly when the cache is empty. Identical exchanges (same name, type, class,
DO bit, pinned upstream set and forwarded client subnet) now share one: the
first query runs it on its own goroutine and context, the rest wait and get
private copies, and the leader alone writes the cache. If the leader's client
leaves, waiters take over instead of failing with its cancellation. Shared
answers are counted in fancydns_upstream_coalesced_total.

validate() accepted OpcodeUpdate, so a dynamic update was forwarded upstream as
an ordinary lookup, and every other opcode got FORMERR. A recursive resolver
implements QUERY only: anything else is now NOTIMP (RFC 1035 4.1.1), checked
before the question count since STATUS carries no question.

Prefetch only fired when a stale entry was served, so the first client after a
hot name expired got a TTL-1 stale answer. A fresh entry is now refreshed in
the background when it has been served at least twice and under a tenth of its
lifetime remains; the hit counter restarts with each stored entry, so a name
nobody comes back for stops being refreshed. Counted in
fancydns_cache_prefetch_total.

miekg/dns matches only the message ID of an upstream reply, so a forged reply
with the right ID for a different question was accepted and cached under the
name that was asked. exchangeOne now requires the reply to carry the question
that was sent (an empty section only for FORMERR, SERVFAIL, NOTIMP and
REFUSED). A mismatch fails the attempt without counting against the upstream,
so a spoofer cannot trip the circuit breaker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 08:58:56 +02:00
1 parent 1a536e7d0e
commit dd1117fd91
8 files changed
+498 -9

No files matched your search

+33
View File
@@ -637,6 +637,12 @@ func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstrea
client := *p.clientFor(up)
client.Timeout = timeout
resp, rtt, err := exchangeContext(ctx, &client, q.Copy(), up.addr)
if err == nil && !answersQuestion(q, resp) {
// Not a failure of the upstream: an off-path attacker who guessed the
// ID and port can send this, and must not be able to trip the breaker
// by doing it. The attempt fails and the next upstream is tried.
return nil, up.addr, errQuestionMismatch
}
if err != nil {
// Cancellation by the caller (including losing a parallel race) says
// nothing about upstream health. Only an attempt failure counts.
@@ -653,6 +659,9 @@ func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstrea
// the budget expires, retain the truncated answer for the client to retry.
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
resp2, rtt2, err2 := exchangeContext(ctx, tcpClient, q.Copy(), up.addr)
if err2 == nil && !answersQuestion(q, resp2) {
err2 = errQuestionMismatch
}
if err := ctx.Err(); err != nil {
return nil, up.addr, err
}
@@ -671,6 +680,30 @@ func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstrea
return resp2, up.addr, nil
}
var errQuestionMismatch = errors.New("response does not answer the question that was asked")
// answersQuestion reports whether resp is a reply to q. The DNS client library
// matches only the message ID, which is 16 bits; without this check anyone who
// lands the ID and the source port can have any name's answer cached under the
// name that was asked. A resolver that refuses before it can answer may leave
// the question out (RFC 1035 §4.1.1), so an empty section is accepted for those
// error codes and nothing else.
func answersQuestion(q, resp *dns.Msg) bool {
if resp == nil || len(q.Question) != 1 {
return false
}
if len(resp.Question) == 0 {
switch resp.Rcode {
case dns.RcodeFormatError, dns.RcodeServerFailure, dns.RcodeNotImplemented, dns.RcodeRefused:
return true
}
return false
}
a, b := q.Question[0], resp.Question[0]
return len(resp.Question) == 1 && a.Qtype == b.Qtype && a.Qclass == b.Qclass &&
strings.EqualFold(dns.Fqdn(a.Name), dns.Fqdn(b.Name))
}
// exchangeContext also interrupts active socket I/O on caller cancellation;
// miekg/dns applies context deadlines but only checks cancellation while dialing.
func exchangeContext(ctx context.Context, client *dns.Client, q *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {