From 91f7a02f2caafd5fbb44ceb190ebee957d444690 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 13:58:55 +0700 Subject: [PATCH] fix(tmpl): parameterize zone name only on DNS-label boundaries Parameterize used strings.ReplaceAll, so an external host that merely ends with the zone name as a substring (e.g. "notreconops.ru." against zone "reconops.ru") was falsely rewritten to "not{{domain_name}}.". Replace only where the zone name sits on a DNS-label boundary (start/ end of string or a non-alphanumeric/hyphen character), and resolve to a fixed point so adjacent occurrences sharing a single boundary character are still both replaced. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/tmpl/tmpl.go | 39 +++++++++++++++++++++++-- internal/tmpl/tmpl_test.go | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go index c0a9fd9..51b4d00 100644 --- a/internal/tmpl/tmpl.go +++ b/internal/tmpl/tmpl.go @@ -7,6 +7,7 @@ package tmpl import ( + "regexp" "strings" "github.com/vasyakrg/dns-autoresolver/internal/model" @@ -17,6 +18,39 @@ const Placeholder = "{{domain_name}}" func zoneName(z string) string { return strings.TrimSuffix(z, ".") } +// replaceZoneBoundary replaces occurrences of the zone name matched by re +// with Placeholder, but only where the match sits on a DNS-label boundary: +// the zone name must not be a suffix of a longer label. re must be built +// from zoneBoundaryPattern for a specific zone name. A boundary is the +// start/end of the string, or any character that cannot appear inside a DNS +// label (i.e. not a letter, digit, or hyphen — a dot qualifies). This +// prevents an external host that merely ends with the zone name as a +// substring (e.g. "notreconops.ru." against zone "reconops.ru") from being +// falsely parameterized. +// +// Replacement runs to a fixed point: a single regexp pass is non-overlapping, +// so two adjacent zone occurrences separated by a single boundary character +// (e.g. "reconops.ru.reconops.ru.") would only have the first one matched, +// since that shared separator is consumed as the first match's right +// boundary and is unavailable as the second match's left boundary. Placeholder +// ends in "}", itself a valid boundary character, so re-running the pass +// against the previous pass's output resolves this without double-counting. +func replaceZoneBoundary(s string, re *regexp.Regexp) string { + for { + next := re.ReplaceAllString(s, "${1}"+Placeholder+"${2}") + if next == s { + return s + } + s = next + } +} + +// zoneBoundaryPattern builds the regexp source used to find z on a +// DNS-label boundary within a record's Name or Value. +func zoneBoundaryPattern(z string) string { + return `(^|[^a-zA-Z0-9-])` + regexp.QuoteMeta(z) + `([^a-zA-Z0-9-]|$)` +} + // Materialize renders a template's records for the given zone, substituting // {{domain_name}} with the zone name (without the trailing dot) in each // record's Name and every Value. @@ -44,15 +78,16 @@ func Materialize(doc dto.TemplateDoc, zone string) []model.Record { // left unchanged. func Parameterize(recs []model.Record, zone string) dto.TemplateDoc { z := zoneName(zone) + re := regexp.MustCompile(zoneBoundaryPattern(z)) out := dto.TemplateDoc{Records: make([]dto.RecordDTO, 0, len(recs))} for _, r := range recs { vals := make([]string, len(r.Values)) for i, v := range r.Values { - vals[i] = strings.ReplaceAll(v, z, Placeholder) + vals[i] = replaceZoneBoundary(v, re) } out.Records = append(out.Records, dto.RecordDTO{ Type: string(r.Type), - Name: strings.ReplaceAll(r.Name, z, Placeholder), + Name: replaceZoneBoundary(r.Name, re), TTL: r.TTL, Values: vals, }) diff --git a/internal/tmpl/tmpl_test.go b/internal/tmpl/tmpl_test.go index b777ec0..cd8336f 100644 --- a/internal/tmpl/tmpl_test.go +++ b/internal/tmpl/tmpl_test.go @@ -43,3 +43,61 @@ func TestParameterizeIsInverseForZoneOccurrences(t *testing.T) { t.Fatalf("external cname value must be untouched: %q", doc.Records[2].Values[0]) } } + +func TestParameterizeDoesNotMatchZoneNameAsSuffixOfLongerLabel(t *testing.T) { + recs := []model.Record{ + {Type: "CNAME", Name: "www.reconops.ru.", TTL: 600, Values: []string{"notreconops.ru."}}, + } + doc := tmpl.Parameterize(recs, "reconops.ru.") + if doc.Records[0].Values[0] != "notreconops.ru." { + t.Fatalf("external host that merely ends with the zone name must be left untouched: %q", doc.Records[0].Values[0]) + } + if doc.Records[0].Name != "www.{{domain_name}}." { + t.Fatalf("name: %q", doc.Records[0].Name) + } +} + +func TestParameterizeApexAndSubdomainBoundaries(t *testing.T) { + recs := []model.Record{ + {Type: "A", Name: "reconops.ru.", TTL: 600, Values: []string{"1.2.3.4"}}, + {Type: "TXT", Name: "_dmarc.reconops.ru.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}}, + {Type: "MX", Name: "reconops.ru.", TTL: 600, Values: []string{"0 pmg2-mail.reconops.ru."}}, + } + doc := tmpl.Parameterize(recs, "reconops.ru.") + if doc.Records[0].Name != "{{domain_name}}." { + t.Fatalf("apex name: %q", doc.Records[0].Name) + } + if doc.Records[1].Name != "_dmarc.{{domain_name}}." { + t.Fatalf("sub name: %q", doc.Records[1].Name) + } + if doc.Records[2].Values[0] != "0 pmg2-mail.{{domain_name}}." { + t.Fatalf("mx value: %q", doc.Records[2].Values[0]) + } +} + +func TestParameterizeReplacesAllOccurrencesInSPFRecord(t *testing.T) { + recs := []model.Record{ + {Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"v=spf1 a:mail.reconops.ru a:pmg2-mail.reconops.ru ~all"}}, + } + doc := tmpl.Parameterize(recs, "reconops.ru.") + want := "v=spf1 a:mail.{{domain_name}} a:pmg2-mail.{{domain_name}} ~all" + if doc.Records[0].Values[0] != want { + t.Fatalf("spf: got %q, want %q", doc.Records[0].Values[0], want) + } +} + +func TestParameterizeReplacesAdjacentZoneOccurrencesSharingASingleBoundary(t *testing.T) { + // Two zone-name occurrences separated by exactly one boundary character + // (the dot). A single non-overlapping regexp pass would only catch the + // first occurrence, since the shared "." is consumed as its right + // boundary and is then unavailable as the second occurrence's left + // boundary. replaceZoneBoundary must still resolve both. + recs := []model.Record{ + {Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"reconops.ru.reconops.ru."}}, + } + doc := tmpl.Parameterize(recs, "reconops.ru.") + want := "{{domain_name}}.{{domain_name}}." + if doc.Records[0].Values[0] != want { + t.Fatalf("adjacent occurrences: got %q, want %q", doc.Records[0].Values[0], want) + } +}