Improve search scope validation and add configuration support

This commit is contained in:
Daniel 2022-03-16 10:29:33 +01:00
parent 014ac058ce
commit 25ce4b7c84
2 changed files with 93 additions and 62 deletions

View file

@ -9,6 +9,8 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/safing/portbase/utils"
"golang.org/x/net/publicsuffix" "golang.org/x/net/publicsuffix"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
@ -16,6 +18,8 @@ import (
"github.com/safing/portmaster/network/netutils" "github.com/safing/portmaster/network/netutils"
) )
const maxSearchDomains = 100
// Scope defines a domain scope and which resolvers can resolve it. // Scope defines a domain scope and which resolvers can resolve it.
type Scope struct { type Scope struct {
Domain string Domain string
@ -153,22 +157,43 @@ func createResolver(resolverURL, source string) (*Resolver, bool, error) {
UpstreamBlockDetection: blockType, UpstreamBlockDetection: blockType,
} }
searchDomains := query.Get("search")
if searchDomains != "" {
err = configureSearchDomains(newResolver, strings.Split(searchDomains, ","), true)
if err != nil {
return nil, false, err
}
}
newResolver.Conn = resolverConnFactory(newResolver) newResolver.Conn = resolverConnFactory(newResolver)
return newResolver, false, nil return newResolver, false, nil
} }
func configureSearchDomains(resolver *Resolver, searches []string) { func configureSearchDomains(resolver *Resolver, searches []string, hardfail bool) error {
// only allow searches for local resolvers // Check all search domains.
for _, value := range searches { for i, value := range searches {
trimmedDomain := strings.Trim(value, ".") trimmedDomain := strings.Trim(value, ".")
if checkSearchScope(trimmedDomain) { err := checkSearchScope(trimmedDomain)
resolver.Search = append(resolver.Search, fmt.Sprintf(".%s.", strings.Trim(value, "."))) if err != nil {
if hardfail {
return fmt.Errorf("failed to validate search domain #%d: %w", i+1, err)
}
log.Warningf("resolver: skipping invalid search domain for resolver %s: %s", resolver, utils.SafeFirst16Chars(value))
} else {
resolver.Search = append(resolver.Search, fmt.Sprintf(".%s.", trimmedDomain))
} }
} }
// cap to mitigate exploitation via malicious local resolver
if len(resolver.Search) > 100 { // Limit search domains to mitigate exploitation via malicious local resolver.
resolver.Search = resolver.Search[:100] if len(resolver.Search) > maxSearchDomains {
if hardfail {
return fmt.Errorf("too many search domains, for security reasons only %d search domains are supported", maxSearchDomains)
}
log.Warningf("resolver: limiting search domains for resolver %s to %d for security reasons", resolver, maxSearchDomains)
resolver.Search = resolver.Search[:maxSearchDomains]
} }
return nil
} }
func getConfiguredResolvers(list []string) (resolvers []*Resolver) { func getConfiguredResolvers(list []string) (resolvers []*Resolver) {
@ -204,7 +229,7 @@ func getSystemResolvers() (resolvers []*Resolver) {
} }
if resolver.Info.IPScope.IsLAN() { if resolver.Info.IPScope.IsLAN() {
configureSearchDomains(resolver, nameserver.Search) configureSearchDomains(resolver, nameserver.Search, false)
} }
resolvers = append(resolvers, resolver) resolvers = append(resolvers, resolver)
@ -355,46 +380,48 @@ func setScopedResolvers(resolvers []*Resolver) {
) )
} }
func checkSearchScope(searchDomain string) (ok bool) { func checkSearchScope(searchDomain string) error {
// sanity check // Sanity check the input.
if len(searchDomain) == 0 || if len(searchDomain) == 0 ||
searchDomain[0] == '.' || searchDomain[0] == '.' ||
searchDomain[len(searchDomain)-1] == '.' { searchDomain[len(searchDomain)-1] == '.' {
return false return fmt.Errorf("invalid (1) search domain: %s", searchDomain)
} }
// add more subdomains to use official publicsuffix package for our cause // Domains that are specifically listed in the special service domains may be
searchDomain = "*.*.*.*.*." + searchDomain // diverted completely.
if domainInScope("."+searchDomain+".", specialServiceDomains) {
return nil
}
// get suffix // In order to check if the search domain is too high up in the hierarchy, we
suffix, icann := publicsuffix.PublicSuffix(searchDomain) // need to add some more subdomains.
// sanity check augmentedSearchDomain := "*.*.*.*.*." + searchDomain
// Get the public suffix of the search domain and if the TLD is managed by ICANN.
suffix, icann := publicsuffix.PublicSuffix(augmentedSearchDomain)
// Sanity check the result.
if len(suffix) == 0 { if len(suffix) == 0 {
return false return fmt.Errorf("invalid (2) search domain: %s", searchDomain)
} }
// inexistent (custom) tlds are okay
// this will include special service domains! (.onion, .bit, ...) // TLDs that are not managed by ICANN (ie. are unofficial) may be
// diverted completely.
// This might include special service domains like ".onion" and ".bit".
if !icann && !strings.Contains(suffix, ".") { if !icann && !strings.Contains(suffix, ".") {
return true return nil
} }
// check if suffix is a special service domain (may be handled fully by local nameserver) // Build the eTLD+1 domain, which is the highest that may be diverted.
if domainInScope("."+suffix+".", specialServiceDomains) { split := len(augmentedSearchDomain) - len(suffix) - 1
return true eTLDplus1 := augmentedSearchDomain[1+strings.LastIndex(augmentedSearchDomain[:split], "."):]
}
// build eTLD+1 // Check if the scope is being violated by checking if the eTLD+1 contains a wildcard.
split := len(searchDomain) - len(suffix) - 1
eTLDplus1 := searchDomain[1+strings.LastIndex(searchDomain[:split], "."):]
// scope check
//nolint:gosimple // want comment
if strings.Contains(eTLDplus1, "*") { if strings.Contains(eTLDplus1, "*") {
// oops, search domain is too high up the hierarchy return fmt.Errorf(`search domain "%s" is dangerously high up the hierarchy, stay at or below "%s"`, searchDomain, eTLDplus1)
return false
} }
return true return nil
} }
// IsResolverAddress returns whether the given ip and port match a configured resolver. // IsResolverAddress returns whether the given ip and port match a configured resolver.

View file

@ -1,40 +1,44 @@
package resolver package resolver
import "testing" import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCheckResolverSearchScope(t *testing.T) { func TestCheckResolverSearchScope(t *testing.T) {
t.Parallel() t.Parallel()
test := func(t *testing.T, domain string, expectedResult bool) {
t.Helper()
if checkSearchScope(domain) != expectedResult {
if expectedResult {
t.Errorf("domain %s failed scope test", domain)
} else {
t.Errorf("domain %s should fail scope test", domain)
}
}
}
// should fail (invalid) // should fail (invalid)
test(t, ".", false) assert.Error(t, checkSearchScope("."))
test(t, ".com.", false) assert.Error(t, checkSearchScope(".com."))
test(t, "com.", false) assert.Error(t, checkSearchScope("com."))
test(t, ".com", false) assert.Error(t, checkSearchScope(".com"))
// should fail (too high scope)
assert.Error(t, checkSearchScope("com"))
assert.Error(t, checkSearchScope("net"))
assert.Error(t, checkSearchScope("org"))
assert.Error(t, checkSearchScope("pvt.k12.ma.us"))
// should succeed // should succeed
test(t, "a.com", true) assert.NoError(t, checkSearchScope("a.com"))
test(t, "b.a.com", true) assert.NoError(t, checkSearchScope("b.a.com"))
test(t, "c.b.a.com", true) assert.NoError(t, checkSearchScope("c.b.a.com"))
assert.NoError(t, checkSearchScope("test.pvt.k12.ma.us"))
test(t, "onion", true) assert.NoError(t, checkSearchScope("onion"))
test(t, "a.onion", true) assert.NoError(t, checkSearchScope("a.onion"))
test(t, "b.a.onion", true) assert.NoError(t, checkSearchScope("b.a.onion"))
test(t, "c.b.a.onion", true) assert.NoError(t, checkSearchScope("c.b.a.onion"))
test(t, "bit", true) assert.NoError(t, checkSearchScope("bit"))
test(t, "a.bit", true) assert.NoError(t, checkSearchScope("a.bit"))
test(t, "b.a.bit", true) assert.NoError(t, checkSearchScope("b.a.bit"))
test(t, "c.b.a.bit", true) assert.NoError(t, checkSearchScope("c.b.a.bit"))
assert.NoError(t, checkSearchScope("doesnotexist"))
assert.NoError(t, checkSearchScope("a.doesnotexist"))
assert.NoError(t, checkSearchScope("b.a.doesnotexist"))
assert.NoError(t, checkSearchScope("c.b.a.doesnotexist"))
} }