Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
98 changes: 98 additions & 0 deletions urlutil/registrable.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,121 @@ package urlutil
import (
"errors"
"fmt"
"net"
"net/url"
"regexp"
"strings"

"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)

// schemePrefix matches a leading URL scheme per RFC 3986 (ALPHA followed by
// 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
Expand Down
74 changes: 74 additions & 0 deletions urlutil/registrable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading