H6 — TSIG replay-window test. New TestCheckTSIG_BadStatus_Refused verifies that when miekg/dns reports a TSIG verification failure via ResponseWriter.TsigStatus (the channel for fudge-window violations, bad MACs, expired timestamps), our plugin refuses. The fudge tolerance itself is miekg/dns's default (300s); documented in tsig.go so operators know the dependency. H7 — No-op UPDATE policy: documented explicitly in update.go. We do NOT bump the SOA on a no-op (deduped) UPDATE — forcing downstream secondaries to AXFR identical content wastes bandwidth and contradicts RFC 2136's intent. Callers wanting to force a serial bump can send a throwaway add+delete pair (touch-UPDATE pattern). M3 — Delete-by-exact-match ignores TTL and class per RFC 2136 §2.5.4. The previous rr.String() comparison included TTL, so an UPDATE with CLASS=NONE TTL=0 (the protocol-required encoding for a delete) failed to match stored RRs at CLASS=IN with non-zero TTL. Now we normalize both sides (TTL=0, class=IN) before invoking dns.IsDuplicate. M4 — validateZoneFiles now actually parses each zone at startup (loadRRs invocation). Previously it only stat()'d the file; corrupt zone content sailed through startup and produced SERVFAIL on the first UPDATE with no startup-time signal. Combined with H3+H4's invariant checks, this turns silent zone corruption into immediate startup failure. M7 — Commit-message sanitization. RR names are attacker-controlled (TSIG only authenticates the sender; the payload is hostile by default). Control characters in commit messages could inject newlines into git log or ANSI sequences into downstream log renderers. New sanitizeForCommitMessage escapes \n, \r, \t, and other C0 controls. New tests: - TestCheckTSIG_BadStatus_Refused (H6) - TestUpdate_DeleteRR_IgnoresTTL (M3) - TestSanitizeForCommitMessage (M7)
105 lines
4.2 KiB
Go
105 lines
4.2 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// tsigResponseFudge is the time tolerance (seconds) embedded in
|
|
// responses we sign. RFC 8945 §10 suggests 300s; we mirror that.
|
|
const tsigResponseFudge = 300
|
|
|
|
// signResponseIfSigned attaches a TSIG record to resp using the
|
|
// request's key name and algorithm. This causes the downstream
|
|
// dns.ResponseWriter to compute and serialize the MAC at WriteMsg
|
|
// time (using the secret from the server's TsigSecret map, which
|
|
// setup.go populated). Per RFC 8945 §5.4.2, the response to a
|
|
// TSIG-signed message MUST itself be signed if the server knows the
|
|
// key — otherwise the client cannot authenticate the answer and
|
|
// rejects it with "expected a TSIG or SIG(0)" (BIND nsupdate's exact
|
|
// complaint).
|
|
//
|
|
// If the request was not TSIG-signed, this is a no-op. If the key is
|
|
// not in the server's TsigSecret map (e.g. unknown key), miekg/dns
|
|
// will skip signing at write time and the response goes back
|
|
// unsigned — that's the correct shape for "I don't have your key."
|
|
func signResponseIfSigned(resp, req *dns.Msg) {
|
|
tsig := req.IsTsig()
|
|
if tsig == nil {
|
|
return
|
|
}
|
|
resp.SetTsig(tsig.Hdr.Name, tsig.Algorithm, tsigResponseFudge, time.Now().Unix())
|
|
}
|
|
|
|
// checkTSIG verifies that the incoming UPDATE message is properly signed
|
|
// with a TSIG key we know about. The actual signature math has already
|
|
// been done by the underlying dns.Server (because setup.go registered
|
|
// our keys in dnsserver.Config.TsigSecret); this function just inspects
|
|
// the result and the key identity.
|
|
//
|
|
// Behavior matrix:
|
|
//
|
|
// No TSIG keys configured → updates are unauthenticated. Caller may
|
|
// still allow if it deems the network safe;
|
|
// we conservatively reject (REFUSED) since
|
|
// the practical use case (Caddy) always
|
|
// signs.
|
|
// TSIG keys configured but message has no TSIG → reject.
|
|
// TSIG present, key name not in our map → reject.
|
|
// TSIG present, signature failed at dns.Server → reject (TsigStatus()).
|
|
// All good → nil.
|
|
func (p *RFC2136) checkTSIG(w dns.ResponseWriter, r *dns.Msg) error {
|
|
tsig := r.IsTsig()
|
|
|
|
if len(p.TSIGKeys) == 0 {
|
|
return fmt.Errorf("no TSIG keys configured; refusing all UPDATEs as a safety default")
|
|
}
|
|
|
|
if tsig == nil {
|
|
return fmt.Errorf("TSIG required but not present")
|
|
}
|
|
|
|
keyName := strings.ToLower(tsig.Hdr.Name)
|
|
if !strings.HasSuffix(keyName, ".") {
|
|
keyName += "."
|
|
}
|
|
key, known := p.TSIGKeys[keyName]
|
|
if !known {
|
|
return fmt.Errorf("unknown TSIG key %q", keyName)
|
|
}
|
|
|
|
// Algorithm pinning: the incoming TSIG can in theory use any
|
|
// algorithm miekg/dns supports, but we only honour the one declared
|
|
// in Corefile. Rejecting algorithm-downgrade attempts is a small
|
|
// but important hardening — without this, an attacker who somehow
|
|
// got a key could downgrade to HMAC-MD5 (which we don't even
|
|
// configure but miekg/dns understands).
|
|
if !strings.EqualFold(tsig.Algorithm, key.Algorithm) {
|
|
return fmt.Errorf("TSIG algorithm mismatch: incoming=%s expected=%s", tsig.Algorithm, key.Algorithm)
|
|
}
|
|
|
|
// The underlying dns.Server verifies the TSIG MAC for us when it
|
|
// has the secret in its TsigSecret map (which setup.go wires up).
|
|
// A nil from TsigStatus means verification succeeded; any non-nil
|
|
// error means the signature was invalid, the time was outside the
|
|
// fudge window, or some other auth failure.
|
|
//
|
|
// H6 — Replay window: the TSIG `fudge` field (RFC 8945 §5.2) is the
|
|
// allowed clock skew between client and server. miekg/dns enforces
|
|
// this in TsigVerify (rejecting with ErrTime if the request's time
|
|
// is too far from local time). We rely on miekg/dns's default
|
|
// tolerance (currently 300s); we do not configure it ourselves.
|
|
// If miekg/dns's default ever changes, our replay-window behavior
|
|
// changes with it — operators should monitor upstream release notes.
|
|
// A future enhancement is to make the fudge configurable via the
|
|
// Corefile (e.g., `tsig-fudge 300`).
|
|
if status := w.TsigStatus(); status != nil {
|
|
return fmt.Errorf("TSIG verification failed for key %q: %w", keyName, status)
|
|
}
|
|
|
|
return nil
|
|
}
|