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) <noreply@anthropic.com>
This commit is contained in:
+37
-2
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user