mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2024-11-16 10:28:29 -07:00
864c91e524
Squashed commit of the following: commit e73bc282d77a11c923a86166035f1b44427d7066 Author: Simon Zolin <s.zolin@adguard.com> Date: Fri Dec 13 17:17:36 2019 +0300 fix commit f8b5c174816c6fd57fb3930cc465318f468fc8ff Author: Simon Zolin <s.zolin@adguard.com> Date: Fri Dec 13 17:03:13 2019 +0300 fix commit 9d5483a2fb89a172218547b5ee356e7122dca609 Author: Simon Zolin <s.zolin@adguard.com> Date: Fri Dec 13 16:54:30 2019 +0300 - fix security checks via PC/SB services
459 lines
12 KiB
Go
459 lines
12 KiB
Go
// Parental Control, Safe Browsing, Safe Search
|
|
|
|
package dnsfilter
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/gob"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AdguardTeam/dnsproxy/upstream"
|
|
"github.com/AdguardTeam/golibs/cache"
|
|
"github.com/AdguardTeam/golibs/log"
|
|
"github.com/miekg/dns"
|
|
"golang.org/x/net/publicsuffix"
|
|
)
|
|
|
|
// Servers to use for resolution of SB/PC server name
|
|
var bootstrapServers = []string{"176.103.130.130", "176.103.130.131"}
|
|
|
|
const dnsTimeout = 3 * time.Second
|
|
const defaultSafebrowsingServer = "https://dns-family.adguard.com/dns-query"
|
|
const defaultParentalServer = "https://dns-family.adguard.com/dns-query"
|
|
const sbTXTSuffix = "sb.dns.adguard.com."
|
|
const pcTXTSuffix = "pc.dns.adguard.com."
|
|
|
|
func (d *Dnsfilter) initSecurityServices() error {
|
|
var err error
|
|
d.safeBrowsingServer = defaultSafebrowsingServer
|
|
d.parentalServer = defaultParentalServer
|
|
opts := upstream.Options{Timeout: dnsTimeout, Bootstrap: bootstrapServers}
|
|
|
|
d.parentalUpstream, err = upstream.AddressToUpstream(d.parentalServer, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.safeBrowsingUpstream, err = upstream.AddressToUpstream(d.safeBrowsingServer, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
expire byte[4]
|
|
res Result
|
|
*/
|
|
func (d *Dnsfilter) setCacheResult(cache cache.Cache, host string, res Result) int {
|
|
var buf bytes.Buffer
|
|
|
|
expire := uint(time.Now().Unix()) + d.Config.CacheTime*60
|
|
var exp []byte
|
|
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("gob.Encode(): %s", err)
|
|
return 0
|
|
}
|
|
val := buf.Bytes()
|
|
_ = cache.Set([]byte(host), val)
|
|
return len(val)
|
|
}
|
|
|
|
func getCachedResult(cache cache.Cache, host string) (Result, bool) {
|
|
data := cache.Get([]byte(host))
|
|
if data == nil {
|
|
return Result{}, false
|
|
}
|
|
|
|
exp := int(binary.BigEndian.Uint32(data[:4]))
|
|
if exp <= int(time.Now().Unix()) {
|
|
cache.Del([]byte(host))
|
|
return Result{}, false
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
buf.Write(data[4:])
|
|
dec := gob.NewDecoder(&buf)
|
|
r := Result{}
|
|
err := dec.Decode(&r)
|
|
if err != nil {
|
|
log.Debug("gob.Decode(): %s", err)
|
|
return Result{}, false
|
|
}
|
|
|
|
return r, true
|
|
}
|
|
|
|
// SafeSearchDomain returns replacement address for search engine
|
|
func (d *Dnsfilter) SafeSearchDomain(host string) (string, bool) {
|
|
val, ok := safeSearchDomains[host]
|
|
return val, ok
|
|
}
|
|
|
|
func (d *Dnsfilter) checkSafeSearch(host string) (Result, 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 := getCachedResult(gctx.safeSearchCache, host)
|
|
if isFound {
|
|
// atomic.AddUint64(&gctx.stats.Safesearch.CacheHits, 1)
|
|
log.Tracef("SafeSearch: found in cache: %s", host)
|
|
return cachedValue, nil
|
|
}
|
|
|
|
safeHost, ok := d.SafeSearchDomain(host)
|
|
if !ok {
|
|
return Result{}, nil
|
|
}
|
|
|
|
res := Result{IsFiltered: true, Reason: FilteredSafeSearch}
|
|
if ip := net.ParseIP(safeHost); ip != nil {
|
|
res.IP = ip
|
|
len := d.setCacheResult(gctx.safeSearchCache, host, res)
|
|
log.Debug("SafeSearch: stored in cache: %s (%d bytes)", host, len)
|
|
return res, nil
|
|
}
|
|
|
|
// TODO this address should be resolved with upstream that was configured in dnsforward
|
|
addrs, err := net.LookupIP(safeHost)
|
|
if err != nil {
|
|
log.Tracef("SafeSearchDomain for %s was found but failed to lookup for %s cause %s", host, safeHost, err)
|
|
return Result{}, err
|
|
}
|
|
|
|
for _, i := range addrs {
|
|
if ipv4 := i.To4(); ipv4 != nil {
|
|
res.IP = ipv4
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(res.IP) == 0 {
|
|
return Result{}, fmt.Errorf("no ipv4 addresses in safe search response for %s", safeHost)
|
|
}
|
|
|
|
// Cache result
|
|
len := d.setCacheResult(gctx.safeSearchCache, host, res)
|
|
log.Debug("SafeSearch: stored in cache: %s (%d bytes)", host, len)
|
|
return res, nil
|
|
}
|
|
|
|
// for each dot, hash it and add it to string
|
|
func hostnameToHashParam(host string) (string, map[string]bool) {
|
|
var hashparam bytes.Buffer
|
|
hashes := map[string]bool{}
|
|
tld, icann := publicsuffix.PublicSuffix(host)
|
|
if !icann {
|
|
// private suffixes like cloudfront.net
|
|
tld = ""
|
|
}
|
|
curhost := host
|
|
for {
|
|
if curhost == "" {
|
|
// we've reached end of string
|
|
break
|
|
}
|
|
if tld != "" && curhost == tld {
|
|
// we've reached the TLD, don't hash it
|
|
break
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte(curhost))
|
|
hashes[hex.EncodeToString(sum[:])] = true
|
|
hashparam.WriteString(fmt.Sprintf("%s.", hex.EncodeToString(sum[0:4])))
|
|
|
|
pos := strings.IndexByte(curhost, byte('.'))
|
|
if pos < 0 {
|
|
break
|
|
}
|
|
curhost = curhost[pos+1:]
|
|
}
|
|
return hashparam.String(), hashes
|
|
}
|
|
|
|
// Find the target hash in TXT response
|
|
func (d *Dnsfilter) processTXT(svc, host string, resp *dns.Msg, hashes map[string]bool) bool {
|
|
for _, a := range resp.Answer {
|
|
txt, ok := a.(*dns.TXT)
|
|
if !ok {
|
|
continue
|
|
}
|
|
log.Tracef("%s: hashes for %s: %v", svc, host, txt.Txt)
|
|
for _, t := range txt.Txt {
|
|
_, ok := hashes[t]
|
|
if ok {
|
|
log.Tracef("%s: matched %s by %s", svc, host, t)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Disabling "dupl": the algorithm of SB/PC is similar, but it uses different data
|
|
// nolint:dupl
|
|
func (d *Dnsfilter) checkSafeBrowsing(host string) (Result, error) {
|
|
if log.GetLevel() >= log.DEBUG {
|
|
timer := log.StartTimer()
|
|
defer timer.LogElapsed("SafeBrowsing lookup for %s", host)
|
|
}
|
|
|
|
// check cache
|
|
cachedValue, isFound := getCachedResult(gctx.safebrowsingCache, host)
|
|
if isFound {
|
|
// atomic.AddUint64(&gctx.stats.Safebrowsing.CacheHits, 1)
|
|
log.Tracef("SafeBrowsing: found in cache: %s", host)
|
|
return cachedValue, nil
|
|
}
|
|
|
|
result := Result{}
|
|
question, hashes := hostnameToHashParam(host)
|
|
question = question + sbTXTSuffix
|
|
|
|
log.Tracef("SafeBrowsing: checking %s: %s", host, question)
|
|
|
|
req := dns.Msg{}
|
|
req.SetQuestion(question, dns.TypeTXT)
|
|
resp, err := d.safeBrowsingUpstream.Exchange(&req)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
if d.processTXT("SafeBrowsing", host, resp, hashes) {
|
|
result.IsFiltered = true
|
|
result.Reason = FilteredSafeBrowsing
|
|
result.Rule = "adguard-malware-shavar"
|
|
}
|
|
|
|
len := d.setCacheResult(gctx.safebrowsingCache, host, result)
|
|
log.Debug("SafeBrowsing: stored in cache: %s (%d bytes)", host, len)
|
|
return result, nil
|
|
}
|
|
|
|
// Disabling "dupl": the algorithm of SB/PC is similar, but it uses different data
|
|
// nolint:dupl
|
|
func (d *Dnsfilter) checkParental(host string) (Result, error) {
|
|
if log.GetLevel() >= log.DEBUG {
|
|
timer := log.StartTimer()
|
|
defer timer.LogElapsed("Parental lookup for %s", host)
|
|
}
|
|
|
|
// check cache
|
|
cachedValue, isFound := getCachedResult(gctx.parentalCache, host)
|
|
if isFound {
|
|
// atomic.AddUint64(&gctx.stats.Parental.CacheHits, 1)
|
|
log.Tracef("Parental: found in cache: %s", host)
|
|
return cachedValue, nil
|
|
}
|
|
|
|
result := Result{}
|
|
question, hashes := hostnameToHashParam(host)
|
|
question = question + pcTXTSuffix
|
|
|
|
log.Tracef("Parental: checking %s: %s", host, question)
|
|
|
|
req := dns.Msg{}
|
|
req.SetQuestion(question, dns.TypeTXT)
|
|
resp, err := d.parentalUpstream.Exchange(&req)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
if d.processTXT("Parental", host, resp, hashes) {
|
|
result.IsFiltered = true
|
|
result.Reason = FilteredParental
|
|
result.Rule = "parental CATEGORY_BLACKLISTED"
|
|
}
|
|
|
|
len := d.setCacheResult(gctx.parentalCache, host, result)
|
|
log.Debug("Parental: stored in cache: %s (%d bytes)", host, len)
|
|
return result, err
|
|
}
|
|
|
|
func httpError(r *http.Request, w http.ResponseWriter, code int, format string, args ...interface{}) {
|
|
text := fmt.Sprintf(format, args...)
|
|
log.Info("DNSFilter: %s %s: %s", r.Method, r.URL, text)
|
|
http.Error(w, text, code)
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeBrowsingEnable(w http.ResponseWriter, r *http.Request) {
|
|
d.Config.SafeBrowsingEnabled = true
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeBrowsingDisable(w http.ResponseWriter, r *http.Request) {
|
|
d.Config.SafeBrowsingEnabled = false
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeBrowsingStatus(w http.ResponseWriter, r *http.Request) {
|
|
data := map[string]interface{}{
|
|
"enabled": d.Config.SafeBrowsingEnabled,
|
|
}
|
|
jsonVal, err := json.Marshal(data)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, err = w.Write(jsonVal)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func parseParametersFromBody(r io.Reader) (map[string]string, error) {
|
|
parameters := map[string]string{}
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if len(line) == 0 {
|
|
// skip empty lines
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
return parameters, errors.New("Got invalid request body")
|
|
}
|
|
parameters[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
return parameters, nil
|
|
}
|
|
|
|
func (d *Dnsfilter) handleParentalEnable(w http.ResponseWriter, r *http.Request) {
|
|
parameters, err := parseParametersFromBody(r.Body)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusBadRequest, "failed to parse parameters from body: %s", err)
|
|
return
|
|
}
|
|
|
|
sensitivity, ok := parameters["sensitivity"]
|
|
if !ok {
|
|
http.Error(w, "Sensitivity parameter was not specified", 400)
|
|
return
|
|
}
|
|
|
|
switch sensitivity {
|
|
case "3":
|
|
break
|
|
case "EARLY_CHILDHOOD":
|
|
sensitivity = "3"
|
|
case "10":
|
|
break
|
|
case "YOUNG":
|
|
sensitivity = "10"
|
|
case "13":
|
|
break
|
|
case "TEEN":
|
|
sensitivity = "13"
|
|
case "17":
|
|
break
|
|
case "MATURE":
|
|
sensitivity = "17"
|
|
default:
|
|
http.Error(w, "Sensitivity must be set to valid value", 400)
|
|
return
|
|
}
|
|
i, err := strconv.Atoi(sensitivity)
|
|
if err != nil {
|
|
http.Error(w, "Sensitivity must be set to valid value", 400)
|
|
return
|
|
}
|
|
d.Config.ParentalSensitivity = i
|
|
d.Config.ParentalEnabled = true
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleParentalDisable(w http.ResponseWriter, r *http.Request) {
|
|
d.Config.ParentalEnabled = false
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleParentalStatus(w http.ResponseWriter, r *http.Request) {
|
|
data := map[string]interface{}{
|
|
"enabled": d.Config.ParentalEnabled,
|
|
}
|
|
if d.Config.ParentalEnabled {
|
|
data["sensitivity"] = d.Config.ParentalSensitivity
|
|
}
|
|
jsonVal, err := json.Marshal(data)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, err = w.Write(jsonVal)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeSearchEnable(w http.ResponseWriter, r *http.Request) {
|
|
d.Config.SafeSearchEnabled = true
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeSearchDisable(w http.ResponseWriter, r *http.Request) {
|
|
d.Config.SafeSearchEnabled = false
|
|
d.Config.ConfigModified()
|
|
}
|
|
|
|
func (d *Dnsfilter) handleSafeSearchStatus(w http.ResponseWriter, r *http.Request) {
|
|
data := map[string]interface{}{
|
|
"enabled": d.Config.SafeSearchEnabled,
|
|
}
|
|
jsonVal, err := json.Marshal(data)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to marshal status json: %s", err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, err = w.Write(jsonVal)
|
|
if err != nil {
|
|
httpError(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (d *Dnsfilter) registerSecurityHandlers() {
|
|
d.Config.HTTPRegister("POST", "/control/safebrowsing/enable", d.handleSafeBrowsingEnable)
|
|
d.Config.HTTPRegister("POST", "/control/safebrowsing/disable", d.handleSafeBrowsingDisable)
|
|
d.Config.HTTPRegister("GET", "/control/safebrowsing/status", d.handleSafeBrowsingStatus)
|
|
|
|
d.Config.HTTPRegister("POST", "/control/parental/enable", d.handleParentalEnable)
|
|
d.Config.HTTPRegister("POST", "/control/parental/disable", d.handleParentalDisable)
|
|
d.Config.HTTPRegister("GET", "/control/parental/status", d.handleParentalStatus)
|
|
|
|
d.Config.HTTPRegister("POST", "/control/safesearch/enable", d.handleSafeSearchEnable)
|
|
d.Config.HTTPRegister("POST", "/control/safesearch/disable", d.handleSafeSearchDisable)
|
|
d.Config.HTTPRegister("GET", "/control/safesearch/status", d.handleSafeSearchStatus)
|
|
}
|