Files
coredns/plugin/cache/cache.go
Nitin Nizhawan e073d1c05b plugin/cache: do not cache SOA-less NODATA responses (#8232)
* plugin/cache: do not cache SOA-less NODATA responses

An upstream may return NOERROR with a non-empty answer that still does not
resolve the question and without an SOA record to bound a negative TTL: a
CNAME chain that does not terminate in a record of the queried type at the
chain's terminal name (an incomplete recursion result from a forwarder).
Because the answer section is non-empty, response.Typify classifies it as
NoError (positive), so the cache plugin stores it keyed on <qname,qtype> and
replays the non-answer to clients until the TTL expires.

Per RFC 2308 section 5, negative responses without an SOA record SHOULD NOT be
cached. Following RFC 1034 section 3.6.2 and RFC 2308 sections 1 and 2.2, the
effective owner name is the target at the end of the CNAME chain, and the
response is NODATA unless it carries the queried type at that terminal name;
this holds for every query type, not just A/AAAA. Skip caching such a response
(mirroring the existing NameError && !hasSOA guard) and let the next query be
resolved upstream again.

An empty answer section is deliberately left cacheable: it is indistinguishable
from a legitimate NOERROR positive response that carries its data outside the
answer section (for example the whoami plugin).

Refs coredns#6958, coredns#5077, coredns#4987.

Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

* plugin/cache: fail closed on malformed CNAME chains in isNODATA

canonicalName now returns a validity flag and rejects chains that are not a
single unambiguous path to a terminal name: an owner with more than one
distinct CNAME target (RFC 2181 section 10.1) and a revisited owner / CNAME
loop (RFC 1034 section 3.6.2). isNODATA treats an invalid chain as a
non-answer, so a SOA-less response with such a chain is not cached. This makes
the classification order-independent (previously the first CNAME per owner
won, so a two-target owner was cacheable or not depending on wire order) and
closes the loop-with-co-located-record case. Duplicate CNAME records naming
the same target are still tolerated.

Adds regression tests for the two-distinct-targets case in both orders, the
CNAME loop with a co-located A, and the tolerated duplicate-identical-CNAME
case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

* plugin/cache: split canonicalName into self-documenting helpers

Extract the per-owner CNAME lookup into uniqueCNAMETarget and loop detection
into a small case-insensitive nameSet type, leaving canonicalName as a short
driver. Signature and algorithm are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

---------

Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
2026-07-29 20:39:49 -07:00

503 lines
16 KiB
Go

// Package cache implements a cache.
package cache
import (
"encoding/binary"
"hash/fnv"
"net"
"strings"
"time"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/pkg/cache"
"github.com/coredns/coredns/plugin/pkg/dnsutil"
"github.com/coredns/coredns/plugin/pkg/response"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
)
// Cache is a plugin that looks up responses in a cache and caches replies.
// It has a success and a denial of existence cache.
type Cache struct {
Next plugin.Handler
Zones []string
zonesMetricLabel string
viewMetricLabel string
ncache *cache.Cache[*item]
ncap int
nttl time.Duration
minnttl time.Duration
pcache *cache.Cache[*item]
pcap int
pttl time.Duration
minpttl time.Duration
failttl time.Duration // TTL for caching SERVFAIL responses
// Prefetch.
prefetch int
duration time.Duration
percentage int
// Stale serve
staleUpTo time.Duration
verifyStale bool
verifyStaleTimeout time.Duration // 0 means wait for upstream until its own timeout (current default).
// Positive/negative zone exceptions
pexcept []string
nexcept []string
// Keep ttl option
keepttl bool
// Testing.
now func() time.Time
}
// New returns an initialized Cache with default settings. It's up to the
// caller to set the Next handler.
func New() *Cache {
return &Cache{
Zones: []string{"."},
pcap: defaultCap,
pcache: cache.New[*item](defaultCap),
pttl: maxTTL,
minpttl: minTTL,
ncap: defaultCap,
ncache: cache.New[*item](defaultCap),
nttl: maxNTTL,
minnttl: minNTTL,
failttl: minNTTL,
prefetch: 0,
duration: 1 * time.Minute,
percentage: 10,
now: time.Now,
}
}
// key returns key under which we store the item, -1 will be returned if we don't store the message.
// Currently we do not cache Truncated, errors zone transfers or dynamic update messages.
// qname holds the already lowercased qname.
func key(qname string, m *dns.Msg, t response.Type, do, cd bool) (bool, uint64) {
// We don't store truncated responses.
if m.Truncated {
return false, 0
}
// Nor errors or Meta or Update.
if t == response.OtherError || t == response.Meta || t == response.Update {
return false, 0
}
// Negative caching requires an SOA record to determine the denial TTL.
if t == response.NameError && !hasSOA(m) {
return false, 0
}
// An upstream may return NOERROR with a non-empty answer that still does not
// resolve the question and without an SOA to bound a negative TTL: a CNAME
// chain that does not terminate in the queried type (an incomplete recursion
// result from a forwarder). This is effectively an SOA-less NODATA response,
// which per RFC 2308 section 5 SHOULD NOT be cached. response.Typify classifies
// it as NoError because the answer section is non-empty, so caching it in the
// positive cache would replay the non-answer to clients until it expires. Skip
// caching so the next query is resolved upstream again. An empty answer section
// is deliberately left cacheable: it is indistinguishable from a legitimate
// NOERROR positive response that carries its data outside the answer section
// (for example the whoami plugin, which answers in the additional section).
if t == response.NoError && !hasSOA(m) && isNODATA(m) {
return false, 0
}
return true, hash(qname, m.Question[0].Qtype, m.Question[0].Qclass, do, cd)
}
func hasSOA(m *dns.Msg) bool {
for _, r := range m.Ns {
if r.Header().Rrtype == dns.TypeSOA {
return true
}
}
return false
}
// isNODATA reports whether a NOERROR response with a non-empty answer section
// does not answer the question. Following RFC 1034 section 3.6.2 and RFC 2308
// sections 1 and 2.2, a query of any type other than CNAME (and ANY) is
// restarted along the CNAME chain, so the effective owner name is the target at
// the end of the CNAME chain that starts at the question name. The response
// answers the question only if it carries a record of the queried type at that
// terminal name (records at any other owner name are irrelevant, and per RFC
// 1034 a CNAME's owner never co-locates other data). This rule is independent of
// the queried type: an MX, TXT, SRV, etc. chain that does not reach the queried
// type is NODATA just like an A or AAAA one. When the chain is malformed (an
// owner with more than one distinct CNAME target, or a loop) it has no
// well-defined terminal name, so the response is treated as NODATA, which errs
// toward re-querying upstream rather than caching a non-answer. An empty answer
// section returns false so that legitimate positive responses carrying data
// outside the answer section (for example the whoami plugin) remain cacheable.
// ANY queries are excluded because any record answers them. Note: a bare DNAME
// (RFC 6672) without its synthesized CNAME is treated as NODATA; standard
// responses include the synthesized CNAME, which the chain walk follows.
func isNODATA(m *dns.Msg) bool {
if len(m.Answer) == 0 {
return false
}
qtype := m.Question[0].Qtype
if qtype == dns.TypeANY {
return false
}
// A CNAME query is answered by the CNAME itself, so the chain is not
// followed; otherwise resolve it to the terminal owner name.
name := m.Question[0].Name
if qtype != dns.TypeCNAME {
terminal, ok := canonicalName(m.Answer, name)
if !ok {
// The CNAME chain is malformed (an owner with more than one
// distinct target, or a loop) and therefore has no well-defined
// QNAME per RFC 2181 section 10.1 and RFC 1034 section 3.6.2. Such
// a response cannot be shown to answer the question, so treat it as
// NODATA and (being SOA-less) leave it uncacheable.
return true
}
name = terminal
}
for _, r := range m.Answer {
h := r.Header()
if h.Rrtype == qtype && strings.EqualFold(h.Name, name) {
return false
}
}
return true
}
// canonicalName follows the owner-linked CNAME chain in answer starting at name
// and returns the terminal target name together with a validity flag. Records
// whose owner is not on the chain are ignored. The chain is invalid (ok=false)
// when it is not a single unambiguous path to a terminal name: an owner that has
// more than one distinct CNAME target violates RFC 2181 section 10.1 (an alias
// has exactly one canonical name), and a revisited owner is a CNAME loop, which
// RFC 1034 section 3.6.2 says must be signalled as an error. Reporting validity
// rather than silently stopping keeps the classification order-independent and
// fail-closed: callers treat a malformed chain as a non-answer. Duplicate CNAME
// records that name the same target are tolerated, since they still describe a
// single canonical name.
func canonicalName(answer []dns.RR, name string) (string, bool) {
visited := nameSet{}
for {
if visited.contains(name) {
// Revisited owner: the chain contains a CNAME loop.
return name, false
}
visited.add(name)
target, ok := uniqueCNAMETarget(answer, name)
if !ok {
// Owner has more than one distinct canonical name.
return name, false
}
if target == "" {
// Terminal owner reached: no CNAME continues the chain.
return name, true
}
name = target
}
}
// uniqueCNAMETarget returns the canonical name that owner is aliased to by a
// CNAME record in answer. ok is false when owner carries more than one distinct
// CNAME target, which violates RFC 2181 section 10.1. When owner has no CNAME the
// returned target is empty and ok is true, marking a terminal owner. Duplicate
// CNAME records naming the same target are tolerated.
func uniqueCNAMETarget(answer []dns.RR, owner string) (target string, ok bool) {
for _, r := range answer {
c, isCNAME := r.(*dns.CNAME)
if !isCNAME || !strings.EqualFold(c.Header().Name, owner) {
continue
}
if target != "" && !strings.EqualFold(target, c.Target) {
return "", false
}
target = c.Target
}
return target, true
}
// nameSet is a set of domain names compared case-insensitively, used to detect
// revisited owners (loops) while walking a CNAME chain.
type nameSet map[string]struct{}
func (s nameSet) contains(name string) bool {
_, ok := s[strings.ToLower(name)]
return ok
}
func (s nameSet) add(name string) {
s[strings.ToLower(name)] = struct{}{}
}
var one = []byte("1")
var zero = []byte("0")
func hash(qname string, qtype, qclass uint16, do, cd bool) uint64 {
h := fnv.New64()
if do {
h.Write(one)
} else {
h.Write(zero)
}
if cd {
h.Write(one)
} else {
h.Write(zero)
}
var qtypeBytes [2]byte
binary.BigEndian.PutUint16(qtypeBytes[:], qtype)
h.Write(qtypeBytes[:])
var qclassBytes [2]byte
binary.BigEndian.PutUint16(qclassBytes[:], qclass)
h.Write(qclassBytes[:])
h.Write([]byte(qname))
return h.Sum64()
}
func computeTTL(msgTTL, minTTL, maxTTL time.Duration) time.Duration {
ttl := min(max(msgTTL, minTTL), maxTTL)
return ttl
}
// ResponseWriter is a response writer that caches the reply message.
type ResponseWriter struct {
dns.ResponseWriter
*Cache
state request.Request
server string // Server handling the request.
do bool // When true the original request had the DO bit set.
cd bool // When true the original request had the CD bit set.
ad bool // When true the original request had the AD bit set.
prefetch bool // When true write nothing back to the client.
remoteAddr net.Addr
wildcardFunc func() string // function to retrieve wildcard name that synthesized the result.
pexcept []string // positive zone exceptions
nexcept []string // negative zone exceptions
}
// prefetchAddr is the synthetic remote address for prefetch requests. There is
// no client connection, and per request.Proto the address type is what selects
// the response-size budget; TCP ensures upstream replies aren't truncated.
var prefetchAddr = &net.TCPAddr{}
// newPrefetchResponseWriter returns a ResponseWriter for prefetch requests.
// Prefetch has no client connection: the inner ResponseWriter is nil, WriteMsg
// short-circuits after caching when w.prefetch is true, and the nil-safe
// overrides below make the remaining dns.ResponseWriter methods well-defined.
func newPrefetchResponseWriter(server string, req *dns.Msg, do, cd bool, c *Cache) *ResponseWriter {
cw := &ResponseWriter{
Cache: c,
server: server,
do: do,
cd: cd,
prefetch: true,
remoteAddr: prefetchAddr,
}
cw.state = request.Request{Req: req}
return cw
}
// RemoteAddr implements the dns.ResponseWriter interface.
func (w *ResponseWriter) RemoteAddr() net.Addr {
if w.remoteAddr != nil {
return w.remoteAddr
}
return w.ResponseWriter.RemoteAddr()
}
// The following overrides make a nil inner ResponseWriter well-defined.
// Prefetch constructs a ResponseWriter with no client connection; WriteMsg
// and Write already short-circuit on w.prefetch before delegating, and
// RemoteAddr uses w.remoteAddr. These cover the rest of the interface.
func (w *ResponseWriter) LocalAddr() net.Addr {
if w.ResponseWriter == nil {
return prefetchAddr
}
return w.ResponseWriter.LocalAddr()
}
func (w *ResponseWriter) Close() error {
if w.ResponseWriter == nil {
return nil
}
return w.ResponseWriter.Close()
}
func (w *ResponseWriter) TsigStatus() error {
if w.ResponseWriter == nil {
return nil
}
return w.ResponseWriter.TsigStatus()
}
func (w *ResponseWriter) TsigTimersOnly(b bool) {
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.TsigTimersOnly(b)
}
func (w *ResponseWriter) Hijack() {
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.Hijack()
}
// WriteMsg implements the dns.ResponseWriter interface.
func (w *ResponseWriter) WriteMsg(res *dns.Msg) error {
res = res.Copy()
mt, _ := response.Typify(res, w.now().UTC())
// key returns empty string for anything we don't want to cache.
hasKey, key := key(w.state.Name(), res, mt, w.do, w.cd)
var duration time.Duration
switch mt {
case response.NameError, response.NoData:
msgTTL := dnsutil.MinimalTTLWithMaximum(res, mt, w.nttl)
duration = computeTTL(msgTTL, w.minnttl, w.nttl)
case response.ServerError:
duration = w.failttl
default:
msgTTL := dnsutil.MinimalTTLWithMaximum(res, mt, w.pttl)
duration = computeTTL(msgTTL, w.minpttl, w.pttl)
}
// Apply capped TTL to this reply to avoid jarring TTL experience 1799 -> 8 (e.g.)
ttl := uint32(duration.Seconds())
res.Answer = filterRRSlice(res.Answer, ttl, false)
res.Ns = filterRRSlice(res.Ns, ttl, false)
res.Extra = filterRRSlice(res.Extra, ttl, false)
if !w.do && !w.ad {
// unset AD bit if requester is not OK with DNSSEC
// But retain AD bit if requester set the AD bit in the request, per RFC6840 5.7-5.8
res.AuthenticatedData = false
}
if hasKey && duration > 0 {
if w.state.Match(res) {
w.set(res, key, mt, duration)
cacheSize.WithLabelValues(w.server, Success, w.zonesMetricLabel, w.viewMetricLabel).Set(float64(w.pcache.Len()))
cacheSize.WithLabelValues(w.server, Denial, w.zonesMetricLabel, w.viewMetricLabel).Set(float64(w.ncache.Len()))
} else {
// Don't log it, but increment counter
cacheDrops.WithLabelValues(w.server, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
}
if w.prefetch {
return nil
}
return w.ResponseWriter.WriteMsg(res)
}
func (w *ResponseWriter) set(m *dns.Msg, key uint64, mt response.Type, duration time.Duration) {
// duration is expected > 0
// and key is valid
switch mt {
case response.NoError, response.Delegation:
if plugin.Zones(w.pexcept).Matches(m.Question[0].Name) != "" {
// zone is in exception list, do not cache
return
}
i := newItem(m, w.now(), duration)
if w.wildcardFunc != nil {
i.wildcard = w.wildcardFunc()
}
if w.pcache.Add(key, i) {
evictions.WithLabelValues(w.server, Success, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
// when pre-fetching, remove the negative cache entry if it exists
if w.prefetch {
w.ncache.Remove(key)
}
case response.NameError, response.NoData, response.ServerError:
if plugin.Zones(w.nexcept).Matches(m.Question[0].Name) != "" {
// zone is in exception list, do not cache
return
}
i := newItem(m, w.now(), duration)
if w.wildcardFunc != nil {
i.wildcard = w.wildcardFunc()
}
if w.ncache.Add(key, i) {
evictions.WithLabelValues(w.server, Denial, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
case response.OtherError:
// don't cache these
default:
log.Warningf("Caching called with unknown classification: %d", mt)
}
}
// Write implements the dns.ResponseWriter interface.
func (w *ResponseWriter) Write(buf []byte) (int, error) {
log.Warning("Caching called with Write: not caching reply")
if w.prefetch {
return 0, nil
}
n, err := w.ResponseWriter.Write(buf)
return n, err
}
// verifyStaleResponseWriter is a response writer that only writes messages if they should replace a
// stale cache entry, and otherwise discards them.
type verifyStaleResponseWriter struct {
*ResponseWriter
refreshed bool // set to true if the last WriteMsg wrote to ResponseWriter, false otherwise.
}
// newVerifyStaleResponseWriter returns a ResponseWriter to be used when verifying stale cache
// entries. It only forward writes if an entry was successfully refreshed according to RFC8767,
// section 4 (response is NoError or NXDomain), and ignores any other response.
func newVerifyStaleResponseWriter(w *ResponseWriter) *verifyStaleResponseWriter {
return &verifyStaleResponseWriter{
w,
false,
}
}
// WriteMsg implements the dns.ResponseWriter interface.
func (w *verifyStaleResponseWriter) WriteMsg(res *dns.Msg) error {
w.refreshed = false
if res.Rcode == dns.RcodeSuccess || res.Rcode == dns.RcodeNameError {
w.refreshed = true
return w.ResponseWriter.WriteMsg(res) // stores to the cache and send to client
}
return nil // else discard
}
const (
maxTTL = dnsutil.MaximumDefaultTTL
minTTL = dnsutil.MinimalDefaultTTL
maxNTTL = dnsutil.MaximumDefaultTTL / 2
minNTTL = dnsutil.MinimalDefaultTTL
defaultCap = 10000 // default capacity of the cache.
// Success is the class for caching positive caching.
Success = "success"
// Denial is the class defined for negative caching.
Denial = "denial"
)