diff --git a/go.mod b/go.mod index 348f18f0..0ed9afc3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index c7090350..a631c01d 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/urlutil/registrable.go b/urlutil/registrable.go index 129e5bd9..82a58839 100644 --- a/urlutil/registrable.go +++ b/urlutil/registrable.go @@ -4,10 +4,12 @@ package urlutil import ( "errors" "fmt" + "net" "net/url" "regexp" "strings" + "golang.org/x/net/idna" "golang.org/x/net/publicsuffix" ) @@ -15,12 +17,108 @@ import ( // ALPHA / DIGIT / "+" / "-" / "."), terminated by "://". var schemePrefix = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+\-.]*://`) +var dottedWireDomain = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +var developmentExactNames = map[string]struct{}{ + "example.com": {}, + "example.net": {}, + "example.org": {}, +} + +var developmentSuffixes = []string{"localhost", "test", "example", "invalid"} + +var specialUseSuffixes = []string{ + "alt", "6tisch.arpa", "eap.arpa", "eap-noob.arpa", "home.arpa", + "in-addr.arpa", "ip6.arpa", "ipv4only.arpa", "resolver.arpa", "service.arpa", + "example", "example.com", "example.net", "example.org", "invalid", "local", + "localhost", "onion", "test", +} + // ErrInvalid wraps every reason Registrable rejects an input — bad URL // syntax, missing host, or a host the public suffix list can't reduce // to a registrable domain (raw IPs, single-label hostnames like // "localhost"). var ErrInvalid = errors.New("urlutil: invalid") +var ( + ErrBrandDomainSyntax = fmt.Errorf("brand domain syntax: %w", ErrInvalid) + ErrBrandDomainRegistrable = fmt.Errorf("brand domain is not registrable: %w", ErrInvalid) + ErrBrandDomainSpecialUse = fmt.Errorf("special-use brand domain is not allowed: %w", ErrInvalid) +) + +// BrandDomainOptions controls BrandRef/BrandKey domain validation. +type BrandDomainOptions struct { + // AllowDevelopmentDomains admits only subdomains of .localhost, .test, + // .example, or .invalid, plus example.com/net/org. It is deliberately + // explicit; callers must not infer it from an environment variable. Bare + // localhost remains invalid and .local is never admitted. + AllowDevelopmentDomains bool +} + +// ValidateBrandDomain validates and canonicalizes a BrandRef/BrandKey domain. +// +// Production names must use portable dotted wire syntax, have a registrable +// domain in the pinned ICANN+PRIVATE Public Suffix List, and not be an IANA +// special-use name. It accepts a bare domain only, never a URL, port, or path. +func ValidateBrandDomain(domain string, opts BrandDomainOptions) (string, error) { + if domain == "" || strings.ContainsAny(domain, " \t\r\n/:@?#") { + return "", ErrBrandDomainSyntax + } + canonical, err := idna.Lookup.ToASCII(strings.TrimSuffix(domain, ".")) + if err != nil { + return "", ErrBrandDomainSyntax + } + canonical = strings.ToLower(canonical) + if !dottedWireDomain.MatchString(canonical) || net.ParseIP(canonical) != nil { + return "", ErrBrandDomainSyntax + } + + development := IsDevelopmentBrandDomain(canonical) + if development && opts.AllowDevelopmentDomains { + return canonical, nil + } + if development || isSpecialUseDomain(canonical) { + return "", ErrBrandDomainSpecialUse + } + + suffix, icann := publicsuffix.PublicSuffix(canonical) + lastLabel := canonical[strings.LastIndexByte(canonical, '.')+1:] + // x/net/publicsuffix reports icann=false for both PRIVATE rules and unknown + // TLDs. A PRIVATE rule is still identifiable because its suffix contains a + // registrable boundary (for example github.io), while an unknown TLD falls + // back to the final label itself. + if suffix == "" || (!icann && suffix == lastLabel) { + return "", ErrBrandDomainRegistrable + } + if _, err := publicsuffix.EffectiveTLDPlusOne(canonical); err != nil { + return "", ErrBrandDomainRegistrable + } + return canonical, nil +} + +// IsDevelopmentBrandDomain reports whether domain is in the protocol's narrow +// reserved-name set for local development and deterministic fixtures. +func IsDevelopmentBrandDomain(domain string) bool { + if _, ok := developmentExactNames[domain]; ok { + return true + } + for _, suffix := range developmentSuffixes { + if strings.HasSuffix(domain, "."+suffix) { + return true + } + } + return false +} + +func isSpecialUseDomain(domain string) bool { + for _, suffix := range specialUseSuffixes { + if domain == suffix || strings.HasSuffix(domain, "."+suffix) { + return true + } + } + return false +} + // Registrable reduces rawURL to its registrable domain (eTLD+1). // // Schemeless inputs such as "abc.google.com/test/v1" are accepted; an diff --git a/urlutil/registrable_test.go b/urlutil/registrable_test.go index cc2e8b47..b89badef 100644 --- a/urlutil/registrable_test.go +++ b/urlutil/registrable_test.go @@ -54,3 +54,77 @@ func TestRegistrable_Invalid(t *testing.T) { }) } } + +func TestValidateBrandDomain(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Ads.Brand.COM", "ads.brand.com"}, + {"brand.co.uk", "brand.co.uk"}, + {"tenant.github.io", "tenant.github.io"}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := urlutil.ValidateBrandDomain(tc.input, urlutil.BrandDomainOptions{}) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateBrandDomain_InvalidProductionNames(t *testing.T) { + tests := []struct { + input string + err error + }{ + {"localhost", urlutil.ErrBrandDomainSyntax}, + {"unknown", urlutil.ErrBrandDomainSyntax}, + {"co.uk", urlutil.ErrBrandDomainRegistrable}, + {"brand.unknown", urlutil.ErrBrandDomainRegistrable}, + {"1.2.3.4", urlutil.ErrBrandDomainSyntax}, + {"https://brand.com", urlutil.ErrBrandDomainSyntax}, + {"brand.local", urlutil.ErrBrandDomainSpecialUse}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := urlutil.ValidateBrandDomain(tc.input, urlutil.BrandDomainOptions{}) + assert.Empty(t, got) + require.ErrorIs(t, err, tc.err) + }) + } +} + +func TestValidateBrandDomain_DevelopmentNamesRequireOptIn(t *testing.T) { + for _, domain := range []string{ + "brand.localhost", "brand.test", "brand.example", "brand.invalid", + "example.com", "example.net", "example.org", + } { + t.Run(domain, func(t *testing.T) { + _, err := urlutil.ValidateBrandDomain(domain, urlutil.BrandDomainOptions{}) + require.ErrorIs(t, err, urlutil.ErrBrandDomainSpecialUse) + + got, err := urlutil.ValidateBrandDomain(domain, urlutil.BrandDomainOptions{ + AllowDevelopmentDomains: true, + }) + require.NoError(t, err) + assert.Equal(t, domain, got) + assert.True(t, urlutil.IsDevelopmentBrandDomain(domain)) + }) + } +} + +func TestValidateBrandDomain_LocalIsNeverDevelopment(t *testing.T) { + assert.False(t, urlutil.IsDevelopmentBrandDomain("brand.local")) + _, err := urlutil.ValidateBrandDomain("brand.local", urlutil.BrandDomainOptions{ + AllowDevelopmentDomains: true, + }) + require.ErrorIs(t, err, urlutil.ErrBrandDomainSpecialUse) +} + +func TestValidateBrandDomain_ErrorDoesNotEchoInput(t *testing.T) { + input := "attacker-controlled.example/path?secret=value" + _, err := urlutil.ValidateBrandDomain(input, urlutil.BrandDomainOptions{}) + require.Error(t, err) + assert.NotContains(t, err.Error(), input) +}