diff --git a/internal/filtering/safesearch.go b/internal/filtering/safesearch.go index f7661dd6f0f..c7db85a70a9 100644 --- a/internal/filtering/safesearch.go +++ b/internal/filtering/safesearch.go @@ -13,8 +13,37 @@ import ( "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/golibs/cache" "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/urlfilter/rules" ) +// SafeSearch interface. +type SafeSearch interface { + // SearchHost returns a replacement address for the search engine host. + SearchHost(host string, qtype uint16) (res *rules.DNSRewrite) + + // CheckHost checks host with safe search engine. + CheckHost(host string, qtype uint16) (res Result, err error) +} + +// SafeSearchConf is a struct with safe search related settings. +type SafeSearchConf struct { + // Enabled indicates if safe search is enabled entirely. + Enabled bool `yaml:"enabled" json:"enabled"` + + // Services flags. Each flag indicates if the corresponding service is + // enabled or disabled. + + Bing bool `yaml:"bing" json:"bing"` + DuckDuckGo bool `yaml:"duckduckgo" json:"duckduckgo"` + Google bool `yaml:"google" json:"google"` + Pixabay bool `yaml:"pixabay" json:"pixabay"` + Yandex bool `yaml:"yandex" json:"yandex"` + YouTube bool `yaml:"youtube" json:"youtube"` + + // CustomResolver is the resolver used by safe search. + CustomResolver Resolver `yaml:"-"` +} + /* expire byte[4] res Result diff --git a/internal/filtering/safesearch/matcher.go b/internal/filtering/safesearch/matcher.go new file mode 100644 index 00000000000..e7d0cc6843c --- /dev/null +++ b/internal/filtering/safesearch/matcher.go @@ -0,0 +1,74 @@ +package safesearch + +import ( + "fmt" + "strings" + + "github.com/AdguardTeam/AdGuardHome/internal/filtering" + "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/urlfilter" + "github.com/AdguardTeam/urlfilter/filterlist" + "github.com/AdguardTeam/urlfilter/rules" +) + +// Matcher interface. +type Matcher interface { + // MatchRequest returns matching safesearch rewrite rules for request. + MatchRequest(dReq *urlfilter.DNSRequest) (rules []*rules.NetworkRule) +} + +// DefaultMatcher is the default safesearch matcher. +type DefaultMatcher struct { + // engine is the DNS filtering engine. + engine *urlfilter.DNSEngine +} + +// NewDefaultMatcher returns new safesearch matcher. listID is used as an +// identifier of the underlying rules list. +func NewDefaultMatcher(listID int, settings filtering.SafeSearchConf) (m *DefaultMatcher, err error) { + m = &DefaultMatcher{} + + err = m.resetRules(listID, settings) + if err != nil { + return nil, err + } + + return m, nil +} + +// type check +var _ Matcher = (*DefaultMatcher)(nil) + +// MatchRequest implements the [Matcher] interface for *DefaultMatcher. +func (m *DefaultMatcher) MatchRequest(dReq *urlfilter.DNSRequest) (rules []*rules.NetworkRule) { + res, _ := m.engine.MatchRequest(dReq) + + return res.DNSRewrites() +} + +// resetRules resets the filtering rules. +func (m *DefaultMatcher) resetRules(listID int, settings filtering.SafeSearchConf) (err error) { + var sb strings.Builder + for service, serviceRules := range safeSearchRules { + if isServiceProtected(settings, service) { + sb.WriteString(serviceRules) + } + } + + strList := &filterlist.StringRuleList{ + ID: listID, + RulesText: sb.String(), + IgnoreCosmetic: true, + } + + rs, err := filterlist.NewRuleStorage([]filterlist.RuleList{strList}) + if err != nil { + return fmt.Errorf("creating rule storage: %w", err) + } + + m.engine = urlfilter.NewDNSEngine(rs) + + log.Info("safesearch: filter %d: reset %d rules", listID, m.engine.RulesCount) + + return nil +} diff --git a/internal/filtering/safesearch/matcher_test.go b/internal/filtering/safesearch/matcher_test.go new file mode 100644 index 00000000000..bf9f87f9d16 --- /dev/null +++ b/internal/filtering/safesearch/matcher_test.go @@ -0,0 +1,75 @@ +package safesearch + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/filtering" + "github.com/AdguardTeam/urlfilter" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var defaultSafeSearchConf = filtering.SafeSearchConf{ + Enabled: true, + Bing: true, + DuckDuckGo: true, + Google: true, + Pixabay: true, + Yandex: true, + YouTube: true, +} + +func TestNewDefaultMatcher(t *testing.T) { + m, err := NewDefaultMatcher(-1, defaultSafeSearchConf) + require.NoError(t, err) + require.NotNil(t, m) +} + +func TestDefaultMatcher_MatchRequest(t *testing.T) { + m, err := NewDefaultMatcher(-1, defaultSafeSearchConf) + require.NoError(t, err) + + testCases := []struct { + name string + host string + want string + dtyp uint16 + }{{ + name: "not_filtered", + host: "test-not-filtered.com", + want: "", + dtyp: dns.TypeA, + }, { + name: "yandex", + host: "yandex.by", + want: "|yandex.by^$dnsrewrite=NOERROR;A;213.180.193.56", + dtyp: dns.TypeA, + }, { + name: "yandex_ru", + host: "yandex.ru", + want: "|yandex.ru^$dnsrewrite=NOERROR;A;213.180.193.56", + dtyp: dns.TypeA, + }, { + name: "google", + host: "www.google.com", + want: "|www.google.com^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com", + dtyp: dns.TypeA, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rws := m.MatchRequest(&urlfilter.DNSRequest{ + Hostname: tc.host, + DNSType: tc.dtyp, + }) + + if tc.want != "" { + require.NotEmpty(t, rws) + assert.Equal(t, tc.want, rws[0].RuleText) + } else { + assert.Empty(t, rws) + } + }) + } +} diff --git a/internal/filtering/safesearch/rules.go b/internal/filtering/safesearch/rules.go new file mode 100644 index 00000000000..46baeb21df3 --- /dev/null +++ b/internal/filtering/safesearch/rules.go @@ -0,0 +1,14 @@ +package safesearch + +// safeSearchRules is a map with rules texts grouped by search providers. +// Source rules downloaded from: +// https://adguardteam.github.io/HostlistsRegistry/assets/engines_safe_search.txt, +// https://adguardteam.github.io/HostlistsRegistry/assets/youtube_safe_search.txt. +var safeSearchRules = map[Service]string{ + Bing: "|www.bing.com^$dnsrewrite=NOERROR;CNAME;strict.bing.com", + DuckDuckGo: "|duckduckgo.com^$dnsrewrite=NOERROR;CNAME;safe.duckduckgo.com\n|start.duckduckgo.com^$dnsrewrite=NOERROR;CNAME;safe.duckduckgo.com\n|www.duckduckgo.com^$dnsrewrite=NOERROR;CNAME;safe.duckduckgo.com", + Google: "|www.google.ad^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ae^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.al^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.am^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.as^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.at^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.az^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ba^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.be^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bf^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bi^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bj^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bs^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.bt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.by^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ca^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cat^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cd^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cf^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ch^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ci^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ao^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.bw^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ck^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.cr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.id^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.il^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.in^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.jp^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ke^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.kr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ls^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ma^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.mz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.nz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.th^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.tz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ug^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.uk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.uz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.ve^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.co.vi^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.af^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ag^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ai^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ar^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.au^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.bd^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.bh^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.bn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.bo^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.br^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.bz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.co^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.cu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.cy^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.do^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ec^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.eg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.et^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.fj^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.gh^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.gi^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.gt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.hk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.jm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.kh^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.kw^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.lb^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ly^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.mm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.mt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.mx^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.my^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.na^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.nf^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ng^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ni^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.np^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.om^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.pa^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.pe^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.pg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ph^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.pk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.pr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.py^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.qa^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.sa^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.sb^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.sg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.sl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.sv^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.tj^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.tr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.tw^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.ua^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.uy^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.vc^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com.vn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.com^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cv^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.cz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.de^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.dj^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.dk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.dm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.dz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ee^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.es^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.fi^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.fm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.fr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ga^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ge^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gp^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.gy^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.hn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.hr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ht^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.hu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ie^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.im^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.iq^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.is^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.it^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.je^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.jo^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.kg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ki^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.kz^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.la^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.li^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.lk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.lt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.lu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.lv^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.md^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.me^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ml^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ms^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mv^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.mw^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ne^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.nl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.no^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.nr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.nu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.pl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.pn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ps^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.pt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ro^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.rs^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ru^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.rw^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sc^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.se^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sh^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.si^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.so^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.sr^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.st^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.td^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tk^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tl^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tm^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tn^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.to^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.tt^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.vg^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.vu^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com\n|www.google.ws^$dnsrewrite=NOERROR;CNAME;forcesafesearch.google.com", + Pixabay: "|pixabay.com^$dnsrewrite=NOERROR;CNAME;safesearch.pixabay.com", + Yandex: "|www.xn--d1acpjx3f.xn--p1ai^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.ya.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.az^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.by^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.co.il^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.com.am^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.com.ge^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.com.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.com.tr^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.com^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.de^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.ee^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.eu^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.fi^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.fr^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.kz^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.lt^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.lv^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.md^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.net^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.org^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.pl^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.tj^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.tm^$dnsrewrite=NOERROR;A;213.180.193.56\n|www.yandex.uz^$dnsrewrite=NOERROR;A;213.180.193.56\n|xn--d1acpjx3f.xn--p1ai^$dnsrewrite=NOERROR;A;213.180.193.56\n|ya.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.az^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.by^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.co.il^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.com.am^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.com.ge^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.com.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.com.tr^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.com^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.de^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.ee^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.eu^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.fi^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.fr^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.kz^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.lt^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.lv^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.md^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.net^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.org^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.pl^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.ru^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.tj^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.tm^$dnsrewrite=NOERROR;A;213.180.193.56\n|yandex.uz^$dnsrewrite=NOERROR;A;213.180.193.56", + YouTube: "|www.youtube.com^$dnsrewrite=NOERROR;CNAME;restrictmoderate.youtube.com\n|m.youtube.com^$dnsrewrite=NOERROR;CNAME;restrictmoderate.youtube.com\n|youtubei.googleapis.com^$dnsrewrite=NOERROR;CNAME;restrictmoderate.youtube.com\n|youtube.googleapis.com^$dnsrewrite=NOERROR;CNAME;restrictmoderate.youtube.com\n|www.youtube-nocookie.com^$dnsrewrite=NOERROR;CNAME;restrictmoderate.youtube.com", +} diff --git a/internal/filtering/safesearch/safesearch.go b/internal/filtering/safesearch/safesearch.go new file mode 100644 index 00000000000..43e684321c4 --- /dev/null +++ b/internal/filtering/safesearch/safesearch.go @@ -0,0 +1,244 @@ +// Package safesearch implements safesearch host matching. +package safesearch + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/gob" + "fmt" + "net" + "strings" + "time" + + "github.com/AdguardTeam/AdGuardHome/internal/filtering" + "github.com/AdguardTeam/golibs/cache" + "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/urlfilter" + "github.com/AdguardTeam/urlfilter/rules" + "github.com/miekg/dns" +) + +// Service is a enum with service names used as search providers. +type Service string + +// Service enum members. +const ( + Bing Service = "bing" + DuckDuckGo Service = "duckduckgo" + Google Service = "google" + Pixabay Service = "pixabay" + Yandex Service = "yandex" + YouTube Service = "youtube" +) + +// isServiceProtected returns true if the service safe search is active. +func isServiceProtected(s filtering.SafeSearchConf, service Service) (ok bool) { + switch service { + case Bing: + return s.Bing + case DuckDuckGo: + return s.DuckDuckGo + case Google: + return s.Google + case Pixabay: + return s.Pixabay + case Yandex: + return s.Yandex + case YouTube: + return s.YouTube + default: + panic(fmt.Errorf("safesearch: invalid sources: not found service %q", service)) + } +} + +// DefaultSafeSearch is the default safesearch struct. +type DefaultSafeSearch struct { + matcher Matcher + safeSearchCache cache.Cache + cacheTime uint + resolver filtering.Resolver +} + +// NewDefaultSafeSearch returns new safesearch struct. +func NewDefaultSafeSearch( + conf filtering.SafeSearchConf, + filteringConf *filtering.Config, +) (ss *DefaultSafeSearch, err error) { + matcher, err := NewDefaultMatcher(filtering.SafeSearchListID, conf) + if err != nil { + return nil, err + } + + var resolver filtering.Resolver + if conf.CustomResolver == nil { + resolver = net.DefaultResolver + } else { + resolver = conf.CustomResolver + } + + return &DefaultSafeSearch{ + matcher: matcher, + safeSearchCache: cache.New(cache.Config{ + EnableLRU: true, + MaxSize: filteringConf.SafeSearchCacheSize, + }), + cacheTime: filteringConf.CacheTime, + resolver: resolver, + }, nil +} + +// type check +var _ filtering.SafeSearch = (*DefaultSafeSearch)(nil) + +// SearchHost implements the [filtering.SafeSearch] interface for *DefaultSafeSearch. +func (ss *DefaultSafeSearch) SearchHost(host string, qtype uint16) (res *rules.DNSRewrite) { + networkRules := ss.matcher.MatchRequest(&urlfilter.DNSRequest{ + Hostname: strings.ToLower(host), + DNSType: qtype, + }) + + if len(networkRules) < 1 { + return nil + } + + return networkRules[0].DNSRewrite +} + +// CheckHost implements the [filtering.SafeSearch] interface for +// *DefaultSafeSearch. +func (ss *DefaultSafeSearch) CheckHost( + host string, + qtype uint16, +) (res filtering.Result, err error) { + if log.GetLevel() >= log.DEBUG { + timer := log.StartTimer() + defer timer.LogElapsed("safesearch: lookup for %s", host) + } + + // Check cache. Return cached result if it was found + cachedValue, isFound := ss.getCachedResult(host) + if isFound { + log.Tracef("safesearch: found in cache: %s", host) + + return cachedValue, nil + } + + rewrite := ss.SearchHost(host, qtype) + if rewrite == nil { + return filtering.Result{}, nil + } + + dRes, err := ss.createResult(rewrite, qtype) + if err != nil { + log.Tracef("SafeSearchDomain for %s was found but failed to lookup cause %s", host, err) + + return filtering.Result{}, err + } + + if dRes != nil { + res = *dRes + ss.setCacheResult(host, res) + + return res, nil + } + + return filtering.Result{}, fmt.Errorf("no ipv4 addresses in safe search response for %s", host) +} + +// createResult create Result object from rewrite rule. +func (ss *DefaultSafeSearch) createResult( + rewrite *rules.DNSRewrite, + qtype uint16, +) (res *filtering.Result, err error) { + res = &filtering.Result{ + Rules: []*filtering.ResultRule{{ + FilterListID: filtering.SafeSearchListID, + }}, + Reason: filtering.FilteredSafeSearch, + IsFiltered: true, + } + + if rewrite.RRType == qtype && (qtype == dns.TypeA || qtype == dns.TypeAAAA) { + ip, ok := rewrite.Value.(net.IP) + if !ok || ip == nil { + return nil, nil + } + + res.Rules[0].IP = ip + + return res, nil + } + + if rewrite.NewCNAME == "" { + return nil, nil + } + + ips, err := ss.resolver.LookupIP(context.Background(), "ip", rewrite.NewCNAME) + if err != nil { + return nil, err + } + + for _, ip := range ips { + if ip = ip.To4(); ip == nil { + continue + } + + res.Rules[0].IP = ip + + return res, nil + } + + return nil, nil +} + +func (ss *DefaultSafeSearch) setCacheResult(host string, res filtering.Result) { + var buf bytes.Buffer + + expire := uint(time.Now().Unix()) + ss.cacheTime*60 + exp := make([]byte, 4) + binary.BigEndian.PutUint32(exp, uint32(expire)) + _, _ = buf.Write(exp) + + enc := gob.NewEncoder(&buf) + err := enc.Encode(res) + if err != nil { + log.Error("safesearch: cache encoding: %s", err) + + return + } + + val := buf.Bytes() + _ = ss.safeSearchCache.Set([]byte(host), val) + + log.Debug("safesearch: stored in cache: %s (%d bytes)", host, len(val)) +} + +func (ss *DefaultSafeSearch) getCachedResult(host string) (res filtering.Result, ok bool) { + res = filtering.Result{} + + data := ss.safeSearchCache.Get([]byte(host)) + if data == nil { + return res, false + } + + exp := int(binary.BigEndian.Uint32(data[:4])) + if exp <= int(time.Now().Unix()) { + ss.safeSearchCache.Del([]byte(host)) + + return res, false + } + + var buf bytes.Buffer + buf.Write(data[4:]) + + dec := gob.NewDecoder(&buf) + err := dec.Decode(&res) + if err != nil { + log.Debug("safesearch: cache decoding: %s", err) + + return filtering.Result{}, false + } + + return res, true +} diff --git a/internal/filtering/safesearch/safesearch_test.go b/internal/filtering/safesearch/safesearch_test.go new file mode 100644 index 00000000000..bdce1eb31c5 --- /dev/null +++ b/internal/filtering/safesearch/safesearch_test.go @@ -0,0 +1,202 @@ +package safesearch + +import ( + "context" + "net" + "strings" + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/aghtest" + "github.com/AdguardTeam/AdGuardHome/internal/filtering" + "github.com/AdguardTeam/urlfilter/rules" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var defaultFilteringConf = &filtering.Config{ + SafeSearchCacheSize: 5000, + CacheTime: 30, +} + +var yandexIP = net.IPv4(213, 180, 193, 56) + +func newForTest( + t testing.TB, + ssConf filtering.SafeSearchConf, + config *filtering.Config, +) (ss *DefaultSafeSearch) { + ss, err := NewDefaultSafeSearch(ssConf, config) + require.NoError(t, err) + + return ss +} + +func TestSafeSearch(t *testing.T) { + ss := newForTest(t, defaultSafeSearchConf, defaultFilteringConf) + val := ss.SearchHost("www.google.com", dns.TypeA) + + assert.Equal(t, &rules.DNSRewrite{NewCNAME: "forcesafesearch.google.com"}, val) +} + +func TestCheckHostSafeSearchYandex(t *testing.T) { + ss := newForTest(t, defaultSafeSearchConf, defaultFilteringConf) + + // Check host for each domain. + for _, host := range []string{ + "yandex.ru", + "yAndeX.ru", + "YANdex.COM", + "yandex.by", + "yandex.kz", + "www.yandex.com", + } { + t.Run(strings.ToLower(host), func(t *testing.T) { + res, err := ss.CheckHost(host, dns.TypeA) + require.NoError(t, err) + + assert.True(t, res.IsFiltered) + + require.Len(t, res.Rules, 1) + + assert.Equal(t, yandexIP, res.Rules[0].IP) + assert.EqualValues(t, filtering.SafeSearchListID, res.Rules[0].FilterListID) + }) + } +} + +func TestCheckHostSafeSearchGoogle(t *testing.T) { + resolver := &aghtest.TestResolver{} + ip, _ := resolver.HostToIPs("forcesafesearch.google.com") + + ss := newForTest(t, defaultSafeSearchConf, defaultFilteringConf) + ss.resolver = resolver + + // Check host for each domain. + for _, host := range []string{ + "www.google.com", + "www.google.im", + "www.google.co.in", + "www.google.iq", + "www.google.is", + "www.google.it", + "www.google.je", + } { + t.Run(host, func(t *testing.T) { + res, err := ss.CheckHost(host, dns.TypeA) + require.NoError(t, err) + + assert.True(t, res.IsFiltered) + + require.Len(t, res.Rules, 1) + + assert.Equal(t, ip, res.Rules[0].IP) + assert.EqualValues(t, filtering.SafeSearchListID, res.Rules[0].FilterListID) + }) + } +} + +func TestSafeSearchCacheYandex(t *testing.T) { + const domain = "yandex.ru" + + conf := &filtering.Config{ + SafeSearchCacheSize: 50, + CacheTime: 30, + } + + ss := newForTest(t, filtering.SafeSearchConf{Enabled: false}, conf) + + // Check host with disabled safesearch. + res, err := ss.CheckHost(domain, dns.TypeA) + require.NoError(t, err) + + assert.False(t, res.IsFiltered) + assert.Empty(t, res.Rules) + + ss = newForTest(t, defaultSafeSearchConf, defaultFilteringConf) + res, err = ss.CheckHost(domain, dns.TypeA) + require.NoError(t, err) + + // For yandex we already know valid IP. + require.Len(t, res.Rules, 1) + assert.Equal(t, res.Rules[0].IP, yandexIP) + + // Check cache. + cachedValue, isFound := ss.getCachedResult(domain) + require.True(t, isFound) + require.Len(t, cachedValue.Rules, 1) + + assert.Equal(t, cachedValue.Rules[0].IP, yandexIP) +} + +func TestSafeSearchCacheGoogle(t *testing.T) { + resolver := &aghtest.TestResolver{} + + conf := &filtering.Config{ + SafeSearchCacheSize: 50, + CacheTime: 30, + } + + ss := newForTest(t, filtering.SafeSearchConf{Enabled: false}, conf) + + const domain = "www.google.ru" + + res, err := ss.CheckHost(domain, dns.TypeA) + require.NoError(t, err) + + assert.False(t, res.IsFiltered) + assert.Empty(t, res.Rules) + + ss = newForTest(t, defaultSafeSearchConf, defaultFilteringConf) + ss.resolver = resolver + + // Lookup for safesearch domain. + rewrite := ss.SearchHost(domain, dns.TypeA) + + ips, err := resolver.LookupIP(context.Background(), "ip", rewrite.NewCNAME) + require.NoError(t, err) + + var ip net.IP + for _, foundIP := range ips { + if foundIP.To4() != nil { + ip = foundIP + + break + } + } + + res, err = ss.CheckHost(domain, dns.TypeA) + require.NoError(t, err) + require.Len(t, res.Rules, 1) + + assert.True(t, res.Rules[0].IP.Equal(ip)) + + // Check cache. + cachedValue, isFound := ss.getCachedResult(domain) + require.True(t, isFound) + require.Len(t, cachedValue.Rules, 1) + + assert.True(t, cachedValue.Rules[0].IP.Equal(ip)) +} + +func BenchmarkSafeSearch(b *testing.B) { + ss := newForTest(b, defaultSafeSearchConf, defaultFilteringConf) + val := ss.SearchHost("www.google.com", dns.TypeA) + assert.Equal(b, "forcesafesearch.google.com", val, "Expected safesearch for google.com to be forcesafesearch.google.com") + + for n := 0; n < b.N; n++ { + _ = ss.SearchHost("www.google.com", dns.TypeA) + } +} + +func BenchmarkSafeSearch_parallel(b *testing.B) { + ss := newForTest(b, defaultSafeSearchConf, defaultFilteringConf) + val := ss.SearchHost("www.google.com", dns.TypeA) + assert.Equal(b, "forcesafesearch.google.com", val, "Expected safesearch for google.com to be forcesafesearch.google.com") + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = ss.SearchHost("www.google.com", dns.TypeA) + } + }) +}