H6/H7/M3/M4/M7: hardening + behavior documentation
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)
This commit is contained in:
parent
d9dad01798
commit
6ab2b6af6d
5 changed files with 185 additions and 15 deletions
|
|
@ -12,14 +12,19 @@ import (
|
|||
|
||||
// captureWriter implements dns.ResponseWriter and stashes the message
|
||||
// passed to WriteMsg so tests can inspect it after handleUpdate returns.
|
||||
//
|
||||
// tsigErr, if non-nil, is what TsigStatus() returns — letting tests
|
||||
// simulate TSIG-verification failure (bad MAC, fudge-window violation,
|
||||
// unknown key etc.).
|
||||
type captureWriter struct {
|
||||
msg *dns.Msg
|
||||
msg *dns.Msg
|
||||
tsigErr error
|
||||
}
|
||||
|
||||
func (cw *captureWriter) WriteMsg(m *dns.Msg) error { cw.msg = m; return nil }
|
||||
func (cw *captureWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (cw *captureWriter) Close() error { return nil }
|
||||
func (cw *captureWriter) TsigStatus() error { return nil }
|
||||
func (cw *captureWriter) TsigStatus() error { return cw.tsigErr }
|
||||
func (cw *captureWriter) TsigTimersOnly(bool) {}
|
||||
func (cw *captureWriter) Hijack() {}
|
||||
func (cw *captureWriter) LocalAddr() net.Addr { return &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)} }
|
||||
|
|
@ -155,6 +160,32 @@ func TestUpdate_DeleteRRset_RemovesAllOfType(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestCheckTSIG_BadStatus_Refused covers H6 — the path where miekg/dns
|
||||
// has reported a TSIG verification failure (bad MAC, fudge-window
|
||||
// violation, expired timestamp, etc.) via dns.ResponseWriter.TsigStatus.
|
||||
// checkTSIG must surface this as an error so ServeDNS refuses the
|
||||
// UPDATE. This is the test that would catch a regression in our
|
||||
// reliance on miekg/dns's default fudge window.
|
||||
func TestCheckTSIG_BadStatus_Refused(t *testing.T) {
|
||||
p := newTestPluginWithZone(t, "auth.example.com")
|
||||
// Configure a key so checkTSIG doesn't short-circuit on "no keys".
|
||||
p.TSIGKeys = map[string]tsigKey{
|
||||
"acme-update-key.": {Algorithm: dns.HmacSHA256, Secret: []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")},
|
||||
}
|
||||
upd := newUpdate("auth.example.com",
|
||||
mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`),
|
||||
)
|
||||
upd.SetTsig("acme-update-key.", dns.HmacSHA256, 300, 0)
|
||||
|
||||
w := &captureWriter{
|
||||
// Simulate miekg/dns reporting "TSIG outside fudge window."
|
||||
tsigErr: dns.ErrTime,
|
||||
}
|
||||
if err := p.checkTSIG(w, upd); err == nil {
|
||||
t.Errorf("checkTSIG accepted request despite TsigStatus reporting %v", dns.ErrTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_UnverifiedCaller_Refused proves the C2 defense-in-depth
|
||||
// contract: handleUpdate refuses any call that doesn't assert TSIG
|
||||
// verification, even if the rest of the message is well-formed and the
|
||||
|
|
@ -178,6 +209,54 @@ func TestUpdate_UnverifiedCaller_Refused(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestUpdate_DeleteRR_IgnoresTTL covers M3: an UPDATE's delete-RR
|
||||
// specifies a different TTL than the stored RR, but per RFC 2136
|
||||
// §3.4.2.4 the deletion must match by owner/class/type/rdata only.
|
||||
// The old String()-based comparison would silently fail to match.
|
||||
func TestUpdate_DeleteRR_IgnoresTTL(t *testing.T) {
|
||||
p := newTestPluginWithZone(t, "auth.example.com")
|
||||
// Add a TXT with TTL 60.
|
||||
runUpdate(t, p, newUpdate("auth.example.com",
|
||||
mustRR(t, `foo.auth.example.com. 60 IN TXT "match-me"`),
|
||||
))
|
||||
// Issue a CLASS=NONE delete with a different TTL (per RFC 2136
|
||||
// §2.5.4, the TTL on a CLASS=NONE delete is supposed to be 0; some
|
||||
// clients get this wrong, and even when they get it right, an
|
||||
// implementation must ignore the value).
|
||||
delRR := mustRR(t, `foo.auth.example.com. 0 IN TXT "match-me"`)
|
||||
delRR.Header().Class = dns.ClassNONE
|
||||
if rcode := runUpdate(t, p, newUpdate("auth.example.com", delRR)); rcode != dns.RcodeSuccess {
|
||||
t.Fatalf("rcode = %d, want NOERROR", rcode)
|
||||
}
|
||||
for _, rr := range readZoneRecords(t, p, "auth.example.com") {
|
||||
if txt, ok := rr.(*dns.TXT); ok && txt.Hdr.Name == "foo.auth.example.com." && txt.Txt[0] == "match-me" {
|
||||
t.Errorf("TTL-differing delete did not remove the RR: %s", rr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeForCommitMessage covers M7: attacker-controlled RR names
|
||||
// (TSIG only authenticates the sender; the payload is hostile by
|
||||
// default) must not inject control characters into commit messages or
|
||||
// downstream log renderers.
|
||||
func TestSanitizeForCommitMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{"with\nnewline", "with\\nnewline"},
|
||||
{"tab\there", "tab\\there"},
|
||||
{"esc\x1b[31mred\x1b[0m", "esc\\x1b[31mred\\x1b[0m"},
|
||||
{"del\x7fchar", "del\\x7fchar"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := sanitizeForCommitMessage(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_OutOfZone_Refused(t *testing.T) {
|
||||
p := newTestPluginWithZone(t, "auth.example.com")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue