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"},