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
61
update.go
61
update.go
|
|
@ -115,8 +115,28 @@ func (p *RFC2136) handleUpdate(w dns.ResponseWriter, r *dns.Msg, verified bool)
|
|||
|
||||
if !changed {
|
||||
// UPDATE was a valid no-op (e.g. only contained adds for RRs
|
||||
// that were already present, deduped away). Return NOERROR
|
||||
// without rewriting the file.
|
||||
// that were already present, deduped away per RFC 2136
|
||||
// §3.4.2.2). Return NOERROR without rewriting the file or
|
||||
// bumping the SOA serial.
|
||||
//
|
||||
// H7 — Policy decision documented:
|
||||
//
|
||||
// We DO NOT bump the SOA serial on no-op UPDATEs. Rationale:
|
||||
// - DNS-wise, nothing changed. Forcing downstream secondaries
|
||||
// (HE) to do an AXFR pull just to re-fetch identical content
|
||||
// wastes bandwidth and is not what RFC 2136 implies.
|
||||
// - The wire-visible cert-issuance chain for ACME does not
|
||||
// depend on the second-UPDATE's serial bump — once the first
|
||||
// UPDATE landed, the SOA already advanced and the auto plugin
|
||||
// reloaded; subsequent identical UPDATEs are spurious and
|
||||
// should be silent.
|
||||
// - Caddy's caddy-dns/rfc2136 client treats NOERROR-no-bump as
|
||||
// "yes I have your record" — which is the truthful answer.
|
||||
//
|
||||
// If a caller wants to force a serial bump for some reason, they
|
||||
// can send a touch-UPDATE that adds-then-deletes a throwaway
|
||||
// record. That's an explicit, intentional pattern and is
|
||||
// supported.
|
||||
return p.updateResp(w, resp, dns.RcodeSuccess)
|
||||
}
|
||||
|
||||
|
|
@ -287,12 +307,43 @@ func applyUpdate(zone string, defaultTTL uint32, rrs []dns.RR, rr dns.RR) ([]dns
|
|||
}
|
||||
|
||||
// summarizeUpdate produces a one-line commit message describing the
|
||||
// UPDATE for git history.
|
||||
// UPDATE for git history. The output is sanitized — Hamilton M7 — to
|
||||
// prevent attacker-controlled RR names (TSIG just authenticates the
|
||||
// sender; the payload is still attacker-controlled) from injecting
|
||||
// control characters into git log, log aggregators, or any downstream
|
||||
// renderer that interprets ANSI/newlines.
|
||||
func summarizeUpdate(zone string, updates []dns.RR) string {
|
||||
var msg string
|
||||
if len(updates) == 1 {
|
||||
return fmt.Sprintf("rfc2136 %s: %s", zone, oneLineOp(updates[0]))
|
||||
msg = fmt.Sprintf("rfc2136 %s: %s", zone, oneLineOp(updates[0]))
|
||||
} else {
|
||||
msg = fmt.Sprintf("rfc2136 %s: %d operations", zone, len(updates))
|
||||
}
|
||||
return fmt.Sprintf("rfc2136 %s: %d operations", zone, len(updates))
|
||||
return sanitizeForCommitMessage(msg)
|
||||
}
|
||||
|
||||
// sanitizeForCommitMessage strips control characters from s, replacing
|
||||
// them with their printable escape form. This keeps git log + downstream
|
||||
// renderers safe from attacker-injected newlines, escape sequences, etc.
|
||||
func sanitizeForCommitMessage(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\n':
|
||||
b.WriteString("\\n")
|
||||
case r == '\r':
|
||||
b.WriteString("\\r")
|
||||
case r == '\t':
|
||||
b.WriteString("\\t")
|
||||
case r < 0x20 || r == 0x7f:
|
||||
// Other C0 controls + DEL: emit \xNN.
|
||||
fmt.Fprintf(&b, "\\x%02x", r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// oneLineOp returns a short human-readable description of a single
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue