coredns-rfc2136/update_test.go
Ryan Malloy 1d2d919728 Phase 1.4: UPDATE opcode handler + TSIG verification
Replaces the Phase-1.3 refuseUpdate() stub with a real RFC 2136 handler.
Caddy via caddy-dns/rfc2136 can now inject and remove records.

UPDATE message handling (update.go):
- Zone section validation: must be exactly one SOA-typed record naming
  a zone we're authoritative for. Returns FORMERR/NOTAUTH otherwise.
- Prerequisites (§3.2): name-exists, RRset-exists, name-NOT-exists,
  RRset-NOT-exists semantics implemented. First failure short-circuits
  with the spec's rcode (NXDOMAIN/NXRRSET/YXDOMAIN/YXRRSET).
- Updates (§3.4.2): add RR, delete RRset (CLASS=ANY+RDLEN=0), delete
  all RRsets at name (CLASS=ANY+TYPE=ANY), delete specific RR (CLASS=
  NONE).
- Apex SOA/NS protected: synthetic and cannot be added or removed via
  UPDATE. Apex wipe (TYPE=ANY at apex) also refused.
- Default TTL applied to incoming records with TTL=0.

TSIG (tsig.go + setup.go):
- setup() now populates dnsserver.Config.TsigSecret so the underlying
  dns.Server auto-verifies signatures via miekg/dns.
- checkTSIG() in ServeDNS gates UPDATEs: rejects if no TSIG, unknown
  key name, algorithm-downgrade attempt, or w.TsigStatus() != nil.
- No TSIG keys configured → all UPDATEs refused (safety default).
- Algorithm pinning prevents downgrade attacks (e.g. forced HMAC-MD5).

Tests (update_test.go): 11 new cases covering happy paths and every
error rcode. Total: 35 top-level test passes, 0 failures.

ServeDNS dispatch now calls handleUpdate after auth gate. The
refuseUpdate() stub is gone. UPDATE end-to-end via nsupdate requires
the custom CoreDNS image (Phase 2) to verify TSIG plumbing on the
dns.Server side.
2026-05-21 10:51:18 -06:00

237 lines
7.3 KiB
Go

package rfc2136
import (
"context"
"testing"
"github.com/miekg/dns"
)
// newUpdate builds a minimal UPDATE message for one zone with zero
// prereqs and the given update RRs. Caddy's caddy-dns/rfc2136 module
// produces messages in this same shape.
func newUpdate(zone string, updates ...dns.RR) *dns.Msg {
m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(zone))
m.Ns = append(m.Ns, updates...)
return m
}
// applyUpdateNoAuth bypasses the TSIG gate so we can test handler
// logic directly without setting up dns.Server-level integration in
// unit tests. End-to-end TSIG verification happens in Phase 2 with
// nsupdate against the live custom CoreDNS binary.
func applyUpdateNoAuth(t *testing.T, p *RFC2136, msg *dns.Msg) (rcode int, response *dns.Msg) {
t.Helper()
w := &captureWriter{}
rcode, _ = p.handleUpdate(w, msg)
return rcode, w.msg
}
func TestUpdate_AddSingleTXT(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
upd := newUpdate("auth.example.com.",
mustRR(t, `token-1.auth.example.com. 60 IN TXT "validation-1"`),
)
rcode, _ := applyUpdateNoAuth(t, p, upd)
if rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %d, want NOERROR", rcode)
}
// Verify via a query through ServeDNS.
req := new(dns.Msg)
req.SetQuestion("token-1.auth.example.com.", dns.TypeTXT)
w := &captureWriter{}
p.ServeDNS(context.Background(), w, req)
if len(w.msg.Answer) != 1 {
t.Fatalf("Answer len = %d, want 1", len(w.msg.Answer))
}
if w.msg.Answer[0].(*dns.TXT).Txt[0] != "validation-1" {
t.Errorf("TXT mismatch: %v", w.msg.Answer[0])
}
}
func TestUpdate_DeleteRRset(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
p.store.Add(mustRR(t, `token-1.auth.example.com. 60 IN TXT "old-token"`))
// CLASS=ANY, RDLEN=0 → delete this specific RRset.
del := &dns.ANY{Hdr: dns.RR_Header{
Name: "token-1.auth.example.com.",
Rrtype: dns.TypeTXT,
Class: dns.ClassANY,
Ttl: 0,
}}
upd := newUpdate("auth.example.com.", del)
rcode, _ := applyUpdateNoAuth(t, p, upd)
if rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %d, want NOERROR", rcode)
}
if got := p.store.Lookup("token-1.auth.example.com.", dns.TypeTXT); got != nil {
t.Errorf("RRset should be gone, still got %v", got)
}
}
func TestUpdate_DeleteAllRRsetsAtName(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN TXT "t"`))
p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN A 192.0.2.1`))
// CLASS=ANY, TYPE=ANY, RDLEN=0 → wipe the name.
del := &dns.ANY{Hdr: dns.RR_Header{
Name: "foo.auth.example.com.",
Rrtype: dns.TypeANY,
Class: dns.ClassANY,
}}
rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", del))
if rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %d", rcode)
}
if p.store.NameExists("foo.auth.example.com.") {
t.Errorf("name should be wiped")
}
}
func TestUpdate_OutsideZone_NOTZONE(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
// Update tries to write into a different zone.
upd := newUpdate("auth.example.com.",
mustRR(t, `evil.different.tld. 60 IN TXT "nope"`),
)
rcode, _ := applyUpdateNoAuth(t, p, upd)
if rcode != dns.RcodeNotZone {
t.Errorf("rcode = %d, want NOTZONE (%d)", rcode, dns.RcodeNotZone)
}
}
func TestUpdate_ZoneSectionNotSOA_FORMERR(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
// Hand-built broken UPDATE: zone section type is TXT, not SOA.
m := new(dns.Msg)
m.SetUpdate("auth.example.com.")
m.Question[0].Qtype = dns.TypeTXT // <-- wrong; must be SOA per RFC 2136
rcode, _ := applyUpdateNoAuth(t, p, m)
if rcode != dns.RcodeFormatError {
t.Errorf("rcode = %d, want FORMERR (%d)", rcode, dns.RcodeFormatError)
}
}
func TestUpdate_UnauthorisedZone_NOTAUTH(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
m := new(dns.Msg)
m.SetUpdate("not-our-zone.com.") // we're not authoritative for this
m.Ns = []dns.RR{mustRR(t, `x.not-our-zone.com. 60 IN TXT "hi"`)}
rcode, _ := applyUpdateNoAuth(t, p, m)
if rcode != dns.RcodeNotAuth {
t.Errorf("rcode = %d, want NOTAUTH (%d)", rcode, dns.RcodeNotAuth)
}
}
func TestUpdate_PrereqNameExists_NXDOMAIN(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
// Prereq: name must exist (CLASS=ANY, TYPE=ANY). It doesn't.
prereq := &dns.ANY{Hdr: dns.RR_Header{
Name: "ghost.auth.example.com.",
Rrtype: dns.TypeANY,
Class: dns.ClassANY,
}}
m := new(dns.Msg)
m.SetUpdate("auth.example.com.")
m.Answer = []dns.RR{prereq}
m.Ns = []dns.RR{mustRR(t, `x.auth.example.com. 60 IN TXT "y"`)}
rcode, _ := applyUpdateNoAuth(t, p, m)
if rcode != dns.RcodeNameError {
t.Errorf("rcode = %d, want NXDOMAIN (%d)", rcode, dns.RcodeNameError)
}
}
func TestUpdate_PrereqRRsetMustNotExist_YXRRSET(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
p.store.Add(mustRR(t, `existing.auth.example.com. 60 IN TXT "present"`))
// Prereq: TXT RRset must NOT exist at this name (CLASS=NONE, TYPE=TXT).
prereq := &dns.ANY{Hdr: dns.RR_Header{
Name: "existing.auth.example.com.",
Rrtype: dns.TypeTXT,
Class: dns.ClassNONE,
}}
m := new(dns.Msg)
m.SetUpdate("auth.example.com.")
m.Answer = []dns.RR{prereq}
rcode, _ := applyUpdateNoAuth(t, p, m)
if rcode != dns.RcodeYXRrset {
t.Errorf("rcode = %d, want YXRRSET (%d)", rcode, dns.RcodeYXRrset)
}
}
func TestUpdate_ApexSOA_RefusedForAdd(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
// Attempting to add an SOA at the apex must be refused — we serve
// SOA synthetically.
soa := mustRR(t, `auth.example.com. 60 IN SOA ns.example.com. admin.auth.example.com. 1 3600 600 604800 60`)
rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", soa))
if rcode != dns.RcodeRefused {
t.Errorf("rcode = %d, want REFUSED (%d) for SOA-at-apex add", rcode, dns.RcodeRefused)
}
}
func TestUpdate_ApexDeletion_Refused(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
// CLASS=ANY, TYPE=ANY at the apex → would wipe the zone. Refuse.
del := &dns.ANY{Hdr: dns.RR_Header{
Name: "auth.example.com.",
Rrtype: dns.TypeANY,
Class: dns.ClassANY,
}}
rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", del))
if rcode != dns.RcodeRefused {
t.Errorf("rcode = %d, want REFUSED for apex wipe", rcode)
}
}
func TestUpdate_DefaultTTL_Applied(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
p.TTL = 120 // configure non-default
// Build an UPDATE add with TTL=0 → plugin should fill in p.TTL.
rr := mustRR(t, `foo.auth.example.com. 0 IN TXT "x"`)
rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", rr))
if rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %d", rcode)
}
got := p.store.Lookup("foo.auth.example.com.", dns.TypeTXT)
if got[0].Header().Ttl != 120 {
t.Errorf("TTL = %d, want default 120", got[0].Header().Ttl)
}
}
func TestUpdate_GenerationBumps(t *testing.T) {
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
start := p.store.generation()
upd := newUpdate("auth.example.com.",
mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`),
)
applyUpdateNoAuth(t, p, upd)
if p.store.generation() <= start {
t.Errorf("generation did not bump: was %d, still %d", start, p.store.generation())
}
}