From 170427576179991a90152856b7c4c693e13ac031 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:50:55 +0800 Subject: [PATCH] validate: trim every trailing dot and read Unicode full stops as labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NormalizeDomain removed at most one trailing dot and only after the IDN conversion, so "example.com.." came back as "example.com." and "bücher.example。" turned the ideographic full stop into a label of its own. Domain() then accepted the trailing-dot form, leaving two entry points with different ideas of the canonical name. Map U+3002, U+FF0E and U+FF61 to "." before anything else and trim all trailing dots, then reject a trailing dot in Domain so callers cannot reintroduce the non-canonical form. Normalizing an accepted domain now returns the input unchanged, which is what FuzzNormalizeDomain checks. Fixes #281 --- internal/validate/validate.go | 15 +++++++++++++-- internal/validate/validate_test.go | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/validate/validate.go b/internal/validate/validate.go index f92c2cd..f1ea12e 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -83,13 +83,21 @@ func checkPort(portStr string) error { return nil } +// fullStopReplacer maps the Unicode characters IDNA treats as label separators +// (UTS #46) onto ASCII dots, so a domain pasted with an ideographic full stop +// is read as the domain the user meant instead of as one extra label (#281). +var fullStopReplacer = strings.NewReplacer("。", ".", ".", ".", "。", ".") + +// normalizeFullStops rewrites every IDNA label separator in s to ".". +func normalizeFullStops(s string) string { return fullStopReplacer.Replace(s) } + // NormalizeDomain turns a target as users type or paste it into the canonical // form every entry point scans: trimmed, lowercase, no trailing dot, IDNs in // punycode. It also strips a URL scheme and path, a :port and a leading "*.", // returning a one-line note for each so the caller can say what it did (#54). // The result is checked with Domain. func NormalizeDomain(input string) (domain string, notes []string, err error) { - s := strings.TrimSpace(input) + s := normalizeFullStops(strings.TrimSpace(input)) if strings.Contains(s, "@") { return "", nil, fmt.Errorf("%q looks like an email address; pass just the domain", input) } @@ -114,7 +122,7 @@ func NormalizeDomain(input string) (domain string, notes []string, err error) { s = s[2:] notes = append(notes, fmt.Sprintf("ignoring the leading *. wildcard; scanning %s", s)) } - s = strings.ToLower(strings.TrimSuffix(s, ".")) + s = strings.ToLower(strings.TrimRight(s, ".")) if !isASCII(s) { ascii, ierr := toASCII(s) if ierr != nil { @@ -137,6 +145,9 @@ func Domain(domain string) error { if len(domain) == 0 { return fmt.Errorf("domain cannot be empty") } + if strings.HasSuffix(domain, ".") { + return fmt.Errorf("domain cannot end with a dot: %s", domain) + } if len(domain) > 253 { return fmt.Errorf("domain exceeds maximum length of 253 characters") } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 55de5e4..2bec26e 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -61,6 +61,7 @@ func TestDomain(t *testing.T) { "example-.com", ".example.com", "example..com", + "example.com.", strings.Repeat("a", 254) + ".com", strings.Repeat("a", 64) + ".com", } @@ -106,6 +107,11 @@ func TestNormalizeDomain(t *testing.T) { {in: "*.example.com", want: "example.com", notes: 1}, {in: "bücher.de", want: "xn--bcher-kva.de", notes: 1}, {in: "_dmarc.example.com", want: "_dmarc.example.com"}, + {in: "example.com..", want: "example.com"}, + {in: "example.com。", want: "example.com"}, + {in: "bücher.example。", want: "xn--bcher-kva.example", notes: 1}, + {in: "bücher.example.。", want: "xn--bcher-kva.example", notes: 1}, + {in: "example.com", want: "example.com"}, {in: "user@example.com", wantErr: "email address"}, {in: "https:///nohost", wantErr: "no host"}, {in: "localhost", wantErr: "invalid domain format"},