From dea8a585f837c52476f98e192d7e8453720bc09d Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Mon, 27 Dec 2021 19:40:39 +0300
Subject: [PATCH] Pull request: filtering: fix rw to subdomain

Updates #4016.

Squashed commit of the following:

commit 83bb15c5a5098103cd17e76b49f456fb4fa73408
Merge: 81905503 313555b1
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Dec 27 19:36:44 2021 +0300

    Merge branch 'master' into 4016-rw-subdomain

commit 81905503c977c004d7ddca1d4e7537bf76443a6e
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Dec 27 19:35:51 2021 +0300

    filtering: fix self reqs

commit b706f481f00232d28dade0bd747a7496753c7deb
Merge: 29cf83de 661f4ece
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Dec 27 19:13:08 2021 +0300

    Merge branch 'master' into 4016-rw-subdomain

commit 29cf83de8e3ff60ea1c471c2a161055b1377392d
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Dec 27 19:07:08 2021 +0300

    all: fix docs

commit 9213fd8ec2b81e65b1198ab241400065f14684b1
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Dec 27 18:44:06 2021 +0300

    filtering: fix rw to subdomain
---
 CHANGELOG.md                             |   2 +
 internal/aghnet/hostscontainer.go        |   6 +-
 internal/aghnet/interfaces.go            |   6 +-
 internal/dhcpd/iprange.go                |   4 +-
 internal/dnsforward/clientid_test.go     |   4 +-
 internal/dnsforward/dns.go               |   6 +-
 internal/dnsforward/dnsforward.go        |   4 +-
 internal/dnsforward/dnsforward_test.go   |   2 +-
 internal/dnsforward/recursiondetector.go |   4 +-
 internal/filtering/filtering.go          |  61 +++++++---
 internal/filtering/rewrites.go           | 142 +++++++++++++++--------
 internal/filtering/rewrites_test.go      |  39 +++++--
 internal/home/auth.go                    |   4 +-
 internal/home/clients.go                 |   4 +-
 internal/home/config.go                  |   2 +-
 internal/home/control.go                 |   2 +-
 internal/querylog/querylog.go            |   2 +-
 internal/querylog/searchcriterion.go     |   2 +-
 internal/querylog/searchparams.go        |   2 +-
 19 files changed, 193 insertions(+), 105 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7e8a3db..4893f839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to
 
 ### Fixed
 
+- Legacy DNS rewrites from a wildcard pattern to a subdomain ([#4016]).
 - Service not being stopped before running the `uninstall` service action
   ([#3868]).
 - Broken `reload` service action on FreeBSD.
@@ -44,6 +45,7 @@ and this project adheres to
 [#3868]: https://github.com/AdguardTeam/AdGuardHome/issues/3868
 [#3987]: https://github.com/AdguardTeam/AdGuardHome/issues/3987
 [#4008]: https://github.com/AdguardTeam/AdGuardHome/issues/4008
+[#4016]: https://github.com/AdguardTeam/AdGuardHome/issues/4016
 
 
 
diff --git a/internal/aghnet/hostscontainer.go b/internal/aghnet/hostscontainer.go
index 612d2f9f..436464e2 100644
--- a/internal/aghnet/hostscontainer.go
+++ b/internal/aghnet/hostscontainer.go
@@ -373,7 +373,7 @@ func (hp *hostsParser) add(ip net.IP, host string) (hostType int) {
 // addPair puts the pair of ip and host to the rules builder if needed.  For
 // each ip the first member of hosts will become the main one.
 func (hp *hostsParser) addPairs(ip net.IP, hosts []string) {
-	// Put the rule in a preproccesed format like:
+	// Put the rule in a processed format like:
 	//
 	//   ip host1 host2 ...
 	//
@@ -488,14 +488,14 @@ func (hp *hostsParser) equalSet(target *netutil.IPMap) (ok bool) {
 		v, hasIP := target.Get(ip)
 		// ok is set to true if the target doesn't contain ip or if the
 		// appropriate hosts set isn't equal to the checked one, i.e. the maps
-		// have at least one disperancy.
+		// have at least one discrepancy.
 		ok = !hasIP || !v.(*stringutil.Set).Equal(val.(*stringutil.Set))
 
 		// Continue only if maps has no discrepancies.
 		return !ok
 	})
 
-	// Return true if every value from the IP map has no disperancies with the
+	// Return true if every value from the IP map has no discrepancies with the
 	// appropriate one from the target.
 	return !ok
 }
diff --git a/internal/aghnet/interfaces.go b/internal/aghnet/interfaces.go
index b0511b0d..a5095919 100644
--- a/internal/aghnet/interfaces.go
+++ b/internal/aghnet/interfaces.go
@@ -8,8 +8,8 @@ import (
 	"github.com/AdguardTeam/golibs/log"
 )
 
-// IPVersion is a documentational alias for int.  Use it when the integer means
-// IP version.
+// IPVersion is a alias for int for documentation purposes.  Use it when the
+// integer means IP version.
 type IPVersion = int
 
 // IP version constants.
@@ -67,7 +67,7 @@ func IfaceIPAddrs(iface NetIface, ipv IPVersion) (ips []net.IP, err error) {
 //
 // It makes up to maxAttempts attempts to get the addresses if there are none,
 // each time using the provided backoff.  Sometimes an interface needs a few
-// seconds to really ititialize.
+// seconds to really initialize.
 //
 // See https://github.com/AdguardTeam/AdGuardHome/issues/2304.
 func IfaceDNSIPAddrs(
diff --git a/internal/dhcpd/iprange.go b/internal/dhcpd/iprange.go
index 55670743..45422957 100644
--- a/internal/dhcpd/iprange.go
+++ b/internal/dhcpd/iprange.go
@@ -14,8 +14,8 @@ import (
 //
 // It is safe for concurrent use.
 //
-// TODO(a.garipov): Perhaps create an optimised version with uint32 for
-// IPv4 ranges?  Or use one of uint128 packages?
+// TODO(a.garipov): Perhaps create an optimized version with uint32 for IPv4
+// ranges?  Or use one of uint128 packages?
 type ipRange struct {
 	start *big.Int
 	end   *big.Int
diff --git a/internal/dnsforward/clientid_test.go b/internal/dnsforward/clientid_test.go
index ad7ac43f..d43de02f 100644
--- a/internal/dnsforward/clientid_test.go
+++ b/internal/dnsforward/clientid_test.go
@@ -31,8 +31,8 @@ func (c testTLSConn) ConnectionState() (cs tls.ConnectionState) {
 
 // testQUICSession is a quicSession for tests.
 type testQUICSession struct {
-	// Session is embedded here simply to make testQUICSession
-	// a quic.Session without acctually implementing all methods.
+	// Session is embedded here simply to make testQUICSession a quic.Session
+	// without actually implementing all methods.
 	quic.Session
 
 	serverName string
diff --git a/internal/dnsforward/dns.go b/internal/dnsforward/dns.go
index 20c86c9f..5c3db14f 100644
--- a/internal/dnsforward/dns.go
+++ b/internal/dnsforward/dns.go
@@ -293,9 +293,9 @@ func (s *Server) processInternalHosts(dctx *dnsContext) (rc resultCode) {
 	req := dctx.proxyCtx.Req
 	q := req.Question[0]
 
-	// Go on processing the AAAA request despite the fact that we don't
-	// support it yet.  The expected behavior here is to respond with an
-	// empty asnwer and not NXDOMAIN.
+	// Go on processing the AAAA request despite the fact that we don't support
+	// it yet.  The expected behavior here is to respond with an empty answer
+	// and not NXDOMAIN.
 	if q.Qtype != dns.TypeA && q.Qtype != dns.TypeAAAA {
 		return resultCodeSuccess
 	}
diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go
index ab2d8a82..fcd438e3 100644
--- a/internal/dnsforward/dnsforward.go
+++ b/internal/dnsforward/dnsforward.go
@@ -443,7 +443,7 @@ func (s *Server) setupResolvers(localAddrs []string) (err error) {
 		&upstream.Options{
 			Bootstrap: bootstraps,
 			Timeout:   defaultLocalTimeout,
-			// TODO(e.burkov): Should we verify server's ceritificates?
+			// TODO(e.burkov): Should we verify server's certificates?
 		},
 	)
 	if err != nil {
@@ -559,7 +559,7 @@ func (s *Server) IsRunning() bool {
 	return s.isRunning
 }
 
-// srvClosedErr is returned when the method can't complete without inacessible
+// srvClosedErr is returned when the method can't complete without inaccessible
 // data from the closing server.
 const srvClosedErr errors.Error = "server is closed"
 
diff --git a/internal/dnsforward/dnsforward_test.go b/internal/dnsforward/dnsforward_test.go
index 512eb87a..d0191c85 100644
--- a/internal/dnsforward/dnsforward_test.go
+++ b/internal/dnsforward/dnsforward_test.go
@@ -892,7 +892,7 @@ func TestBlockedBySafeBrowsing(t *testing.T) {
 
 func TestRewrite(t *testing.T) {
 	c := &filtering.Config{
-		Rewrites: []filtering.RewriteEntry{{
+		Rewrites: []*filtering.LegacyRewrite{{
 			Domain: "test.com",
 			Answer: "1.2.3.4",
 			Type:   dns.TypeA,
diff --git a/internal/dnsforward/recursiondetector.go b/internal/dnsforward/recursiondetector.go
index 4555f4b0..870779f2 100644
--- a/internal/dnsforward/recursiondetector.go
+++ b/internal/dnsforward/recursiondetector.go
@@ -78,8 +78,8 @@ func newRecursionDetector(ttl time.Duration, suspectsNum uint) (rd *recursionDet
 // msgToSignature converts msg into it's signature represented in bytes.
 func msgToSignature(msg dns.Msg) (sig []byte) {
 	sig = make([]byte, uint16sz*2+netutil.MaxDomainNameLen)
-	// The binary.BigEndian byte order is used everywhere except when the
-	// real machine's endianess is needed.
+	// The binary.BigEndian byte order is used everywhere except when the real
+	// machine's endianness is needed.
 	byteOrder := binary.BigEndian
 	byteOrder.PutUint16(sig[0:], msg.Id)
 	q := msg.Question[0]
diff --git a/internal/filtering/filtering.go b/internal/filtering/filtering.go
index f4af7ad2..a2a8ff21 100644
--- a/internal/filtering/filtering.go
+++ b/internal/filtering/filtering.go
@@ -28,7 +28,7 @@ import (
 
 // The IDs of built-in filter lists.
 //
-// Keep in sync with client/src/helpers/contants.js.
+// Keep in sync with client/src/helpers/constants.js.
 const (
 	CustomListID = -iota
 	SysHostsListID
@@ -80,7 +80,7 @@ type Config struct {
 	ParentalCacheSize     uint `yaml:"parental_cache_size"`     // (in bytes)
 	CacheTime             uint `yaml:"cache_time"`              // Element's TTL (in minutes)
 
-	Rewrites []RewriteEntry `yaml:"rewrites"`
+	Rewrites []*LegacyRewrite `yaml:"rewrites"`
 
 	// Names of services to block (globally).
 	// Per-client settings can override this configuration.
@@ -161,9 +161,14 @@ type DNSFilter struct {
 
 // Filter represents a filter list
 type Filter struct {
-	ID       int64  // auto-assigned when filter is added (see nextFilterID)
-	Data     []byte `yaml:"-"` // List of rules divided by '\n'
-	FilePath string `yaml:"-"` // Path to a filtering rules file
+	// FilePath is the path to a filtering rules list file.
+	FilePath string `yaml:"-"`
+
+	// Data is the content of the file.
+	Data []byte `yaml:"-"`
+
+	// ID is automatically assigned when filter is added using nextFilterID.
+	ID int64
 }
 
 // Reason holds an enum detailing why it was filtered or not filtered
@@ -281,8 +286,14 @@ func (d *DNSFilter) WriteDiskConfig(c *Config) {
 	c.Rewrites = cloneRewrites(c.Rewrites)
 }
 
-func cloneRewrites(entries []RewriteEntry) (clone []RewriteEntry) {
-	return append([]RewriteEntry(nil), entries...)
+// cloneRewrites returns a deep copy of entries.
+func cloneRewrites(entries []*LegacyRewrite) (clone []*LegacyRewrite) {
+	clone = make([]*LegacyRewrite, len(entries))
+	for i, rw := range entries {
+		clone[i] = rw.clone()
+	}
+
+	return clone
 }
 
 // SetFilters - set new filters (synchronously or asynchronously)
@@ -477,10 +488,8 @@ func (d *DNSFilter) matchSysHosts(
 }
 
 // matchSysHostsIntl actually matches the request.  It's separated to avoid
-// perfoming checks twice.
-func (d *DNSFilter) matchSysHostsIntl(
-	req *urlfilter.DNSRequest,
-) (res Result, err error) {
+// performing checks twice.
+func (d *DNSFilter) matchSysHostsIntl(req *urlfilter.DNSRequest) (res Result, err error) {
 	dnsres, _ := d.EtcHosts.MatchRequest(*req)
 	if dnsres == nil {
 		return res, nil
@@ -530,15 +539,25 @@ func (d *DNSFilter) processRewrites(host string, qtype uint16) (res Result) {
 	cnames := stringutil.NewSet()
 	origHost := host
 	for matched && len(rewrites) > 0 && rewrites[0].Type == dns.TypeCNAME {
-		rwAns := rewrites[0].Answer
+		rw := rewrites[0]
+		rwPat := rw.Domain
+		rwAns := rw.Answer
 
 		log.Debug("rewrite: cname for %s is %s", host, rwAns)
 
-		if host == rwAns {
-			// Rewrite of a domain onto itself is an exception rule.
-			res.Reason = NotFilteredNotFound
+		if origHost == rwAns || rwPat == rwAns {
+			// Either a request for the hostname itself or a rewrite of
+			// a pattern onto itself, both of which are an exception rules.
+			// Return a not filtered result.
+			return Result{}
+		} else if host == rwAns && isWildcard(rwPat) {
+			// An "*.example.com → sub.example.com" rewrite matching in a loop.
+			//
+			// See https://github.com/AdguardTeam/AdGuardHome/issues/4016.
 
-			return res
+			res.CanonName = host
+
+			break
 		}
 
 		host = rwAns
@@ -560,7 +579,7 @@ func (d *DNSFilter) processRewrites(host string, qtype uint16) (res Result) {
 
 // setRewriteResult sets the Reason or IPList of res if necessary.  res must not
 // be nil.
-func setRewriteResult(res *Result, host string, rewrites []RewriteEntry, qtype uint16) {
+func setRewriteResult(res *Result, host string, rewrites []*LegacyRewrite, qtype uint16) {
 	for _, rw := range rewrites {
 		if rw.Type == qtype && (qtype == dns.TypeA || qtype == dns.TypeAAAA) {
 			if rw.IP == nil {
@@ -932,12 +951,18 @@ func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
 	err := d.initSecurityServices()
 	if err != nil {
 		log.Error("filtering: initialize services: %s", err)
+
 		return nil
 	}
 
 	if c != nil {
 		d.Config = *c
-		d.prepareRewrites()
+		err = d.prepareRewrites()
+		if err != nil {
+			log.Error("rewrites: preparing: %s", err)
+
+			return nil
+		}
 	}
 
 	bsvcs := []string{}
diff --git a/internal/filtering/rewrites.go b/internal/filtering/rewrites.go
index 28a56de6..dab4c034 100644
--- a/internal/filtering/rewrites.go
+++ b/internal/filtering/rewrites.go
@@ -4,19 +4,24 @@ package filtering
 
 import (
 	"encoding/json"
+	"fmt"
 	"net"
 	"net/http"
 	"sort"
 	"strings"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
+	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
+	"github.com/AdguardTeam/golibs/netutil"
 	"github.com/miekg/dns"
 )
 
-// RewriteEntry is a rewrite array element
-type RewriteEntry struct {
-	// Domain is the domain for which this rewrite should work.
+// LegacyRewrite is a single legacy DNS rewrite record.
+//
+// Instances of *LegacyRewrite must never be nil.
+type LegacyRewrite struct {
+	// Domain is the domain pattern for which this rewrite should work.
 	Domain string `yaml:"domain"`
 
 	// Answer is the IP address, canonical name, or one of the special
@@ -24,77 +29,96 @@ type RewriteEntry struct {
 	Answer string `yaml:"answer"`
 
 	// IP is the IP address that should be used in the response if Type is
-	// A or AAAA.
+	// dns.TypeA or dns.TypeAAAA.
 	IP net.IP `yaml:"-"`
 
 	// Type is the DNS record type: A, AAAA, or CNAME.
 	Type uint16 `yaml:"-"`
 }
 
-// equal returns true if the entry is considered equal to the other.
-func (e *RewriteEntry) equal(other RewriteEntry) (ok bool) {
-	return e.Domain == other.Domain && e.Answer == other.Answer
+// clone returns a deep clone of rw.
+func (rw *LegacyRewrite) clone() (cloneRW *LegacyRewrite) {
+	return &LegacyRewrite{
+		Domain: rw.Domain,
+		Answer: rw.Answer,
+		IP:     netutil.CloneIP(rw.IP),
+		Type:   rw.Type,
+	}
 }
 
-// matchesQType returns true if the entry matched qtype.
-func (e *RewriteEntry) matchesQType(qtype uint16) (ok bool) {
+// equal returns true if the rw is equal to the other.
+func (rw *LegacyRewrite) equal(other *LegacyRewrite) (ok bool) {
+	return rw.Domain == other.Domain && rw.Answer == other.Answer
+}
+
+// matchesQType returns true if the entry matches the question type qt.
+func (rw *LegacyRewrite) matchesQType(qt uint16) (ok bool) {
 	// Add CNAMEs, since they match for all types requests.
-	if e.Type == dns.TypeCNAME {
+	if rw.Type == dns.TypeCNAME {
 		return true
 	}
 
 	// Reject types other than A and AAAA.
-	if qtype != dns.TypeA && qtype != dns.TypeAAAA {
+	if qt != dns.TypeA && qt != dns.TypeAAAA {
 		return false
 	}
 
 	// If the types match or the entry is set to allow only the other type,
 	// include them.
-	return e.Type == qtype || e.IP == nil
+	return rw.Type == qt || rw.IP == nil
 }
 
 // normalize makes sure that the a new or decoded entry is normalized with
 // regards to domain name case, IP length, and so on.
-func (e *RewriteEntry) normalize() {
-	// TODO(a.garipov): Write a case-agnostic version of strings.HasSuffix
-	// and use it in matchDomainWildcard instead of using strings.ToLower
+//
+// If rw is nil, it returns an errors.
+func (rw *LegacyRewrite) normalize() (err error) {
+	if rw == nil {
+		return errors.Error("nil rewrite entry")
+	}
+
+	// TODO(a.garipov): Write a case-agnostic version of strings.HasSuffix and
+	// use it in matchDomainWildcard instead of using strings.ToLower
 	// everywhere.
-	e.Domain = strings.ToLower(e.Domain)
+	rw.Domain = strings.ToLower(rw.Domain)
 
-	switch e.Answer {
+	switch rw.Answer {
 	case "AAAA":
-		e.IP = nil
-		e.Type = dns.TypeAAAA
+		rw.IP = nil
+		rw.Type = dns.TypeAAAA
 
-		return
+		return nil
 	case "A":
-		e.IP = nil
-		e.Type = dns.TypeA
+		rw.IP = nil
+		rw.Type = dns.TypeA
 
-		return
+		return nil
 	default:
 		// Go on.
 	}
 
-	ip := net.ParseIP(e.Answer)
+	ip := net.ParseIP(rw.Answer)
 	if ip == nil {
-		e.Type = dns.TypeCNAME
+		rw.Type = dns.TypeCNAME
 
-		return
+		return nil
 	}
 
 	ip4 := ip.To4()
 	if ip4 != nil {
-		e.IP = ip4
-		e.Type = dns.TypeA
+		rw.IP = ip4
+		rw.Type = dns.TypeA
 	} else {
-		e.IP = ip
-		e.Type = dns.TypeAAAA
+		rw.IP = ip
+		rw.Type = dns.TypeAAAA
 	}
+
+	return nil
 }
 
-func isWildcard(host string) bool {
-	return len(host) > 1 && host[0] == '*' && host[1] == '.'
+// isWildcard returns true if pat is a wildcard domain pattern.
+func isWildcard(pat string) bool {
+	return len(pat) > 1 && pat[0] == '*' && pat[1] == '.'
 }
 
 // matchDomainWildcard returns true if host matches the wildcard pattern.
@@ -110,16 +134,16 @@ func matchDomainWildcard(host, wildcard string) (ok bool) {
 //   wildcard > exact
 //   lower level wildcard > higher level wildcard
 //
-type rewritesSorted []RewriteEntry
+type rewritesSorted []*LegacyRewrite
 
 // Len implements the sort.Interface interface for legacyRewritesSorted.
-func (a rewritesSorted) Len() int { return len(a) }
+func (a rewritesSorted) Len() (l int) { return len(a) }
 
 // Swap implements the sort.Interface interface for legacyRewritesSorted.
 func (a rewritesSorted) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
 
 // Less implements the sort.Interface interface for legacyRewritesSorted.
-func (a rewritesSorted) Less(i, j int) bool {
+func (a rewritesSorted) Less(i, j int) (less bool) {
 	if a[i].Type == dns.TypeCNAME && a[j].Type != dns.TypeCNAME {
 		return true
 	} else if a[i].Type != dns.TypeCNAME && a[j].Type == dns.TypeCNAME {
@@ -136,14 +160,20 @@ func (a rewritesSorted) Less(i, j int) bool {
 		}
 	}
 
-	// both are wildcards
+	// Both are wildcards.
 	return len(a[i].Domain) > len(a[j].Domain)
 }
 
-func (d *DNSFilter) prepareRewrites() {
-	for i := range d.Rewrites {
-		d.Rewrites[i].normalize()
+// prepareRewrites normalizes and validates all legacy DNS rewrites.
+func (d *DNSFilter) prepareRewrites() (err error) {
+	for i, r := range d.Rewrites {
+		err = r.normalize()
+		if err != nil {
+			return fmt.Errorf("at index %d: %w", i, err)
+		}
 	}
+
+	return nil
 }
 
 // findRewrites returns the list of matched rewrite entries.  If rewrites are
@@ -154,10 +184,10 @@ func (d *DNSFilter) prepareRewrites() {
 // host is matched exactly, wildcard entries aren't returned.  If the host
 // matched by wildcards, return the most specific for the question type.
 func findRewrites(
-	entries []RewriteEntry,
+	entries []*LegacyRewrite,
 	host string,
 	qtype uint16,
-) (rewrites []RewriteEntry, matched bool) {
+) (rewrites []*LegacyRewrite, matched bool) {
 	for _, e := range entries {
 		if e.Domain != host && !matchDomainWildcard(host, e.Domain) {
 			continue
@@ -224,23 +254,32 @@ func (d *DNSFilter) handleRewriteList(w http.ResponseWriter, r *http.Request) {
 }
 
 func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) {
-	jsent := rewriteEntryJSON{}
-	err := json.NewDecoder(r.Body).Decode(&jsent)
+	rwJSON := rewriteEntryJSON{}
+	err := json.NewDecoder(r.Body).Decode(&rwJSON)
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
 
 		return
 	}
 
-	ent := RewriteEntry{
-		Domain: jsent.Domain,
-		Answer: jsent.Answer,
+	rw := &LegacyRewrite{
+		Domain: rwJSON.Domain,
+		Answer: rwJSON.Answer,
 	}
-	ent.normalize()
+
+	err = rw.normalize()
+	if err != nil {
+		// Shouldn't happen currently, since normalize only returns a non-nil
+		// error when a rewrite is nil, but be change-proof.
+		aghhttp.Error(r, w, http.StatusBadRequest, "normalizing: %s", err)
+
+		return
+	}
+
 	d.confLock.Lock()
-	d.Config.Rewrites = append(d.Config.Rewrites, ent)
+	d.Config.Rewrites = append(d.Config.Rewrites, rw)
 	d.confLock.Unlock()
-	log.Debug("rewrite: added element: %s -> %s [%d]", ent.Domain, ent.Answer, len(d.Config.Rewrites))
+	log.Debug("rewrite: added element: %s -> %s [%d]", rw.Domain, rw.Answer, len(d.Config.Rewrites))
 
 	d.Config.ConfigModified()
 }
@@ -254,11 +293,12 @@ func (d *DNSFilter) handleRewriteDelete(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	entDel := RewriteEntry{
+	entDel := &LegacyRewrite{
 		Domain: jsent.Domain,
 		Answer: jsent.Answer,
 	}
-	arr := []RewriteEntry{}
+	arr := []*LegacyRewrite{}
+
 	d.confLock.Lock()
 	for _, ent := range d.Config.Rewrites {
 		if ent.equal(entDel) {
diff --git a/internal/filtering/rewrites_test.go b/internal/filtering/rewrites_test.go
index 549530c6..5c3de110 100644
--- a/internal/filtering/rewrites_test.go
+++ b/internal/filtering/rewrites_test.go
@@ -15,7 +15,7 @@ func TestRewrites(t *testing.T) {
 	d := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 
-	d.Rewrites = []RewriteEntry{{
+	d.Rewrites = []*LegacyRewrite{{
 		// This one and below are about CNAME, A and AAAA.
 		Domain: "somecname",
 		Answer: "somehost.com",
@@ -66,8 +66,12 @@ func TestRewrites(t *testing.T) {
 	}, {
 		Domain: "BIGHOST.COM",
 		Answer: "1.2.3.7",
+	}, {
+		Domain: "*.issue4016.com",
+		Answer: "sub.issue4016.com",
 	}}
-	d.prepareRewrites()
+
+	require.NoError(t, d.prepareRewrites())
 
 	testCases := []struct {
 		name       string
@@ -153,6 +157,20 @@ func TestRewrites(t *testing.T) {
 		wantIPs:    nil,
 		wantReason: Rewritten,
 		dtyp:       dns.TypeHTTPS,
+	}, {
+		name:       "issue4016",
+		host:       "www.issue4016.com",
+		wantCName:  "sub.issue4016.com",
+		wantIPs:    nil,
+		wantReason: Rewritten,
+		dtyp:       dns.TypeA,
+	}, {
+		name:       "issue4016_self",
+		host:       "sub.issue4016.com",
+		wantCName:  "",
+		wantIPs:    nil,
+		wantReason: NotFilteredNotFound,
+		dtyp:       dns.TypeA,
 	}}
 
 	for _, tc := range testCases {
@@ -173,7 +191,7 @@ func TestRewritesLevels(t *testing.T) {
 	d := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Exact host, wildcard L2, wildcard L3.
-	d.Rewrites = []RewriteEntry{{
+	d.Rewrites = []*LegacyRewrite{{
 		Domain: "host.com",
 		Answer: "1.1.1.1",
 		Type:   dns.TypeA,
@@ -186,7 +204,8 @@ func TestRewritesLevels(t *testing.T) {
 		Answer: "3.3.3.3",
 		Type:   dns.TypeA,
 	}}
-	d.prepareRewrites()
+
+	require.NoError(t, d.prepareRewrites())
 
 	testCases := []struct {
 		name string
@@ -219,7 +238,7 @@ func TestRewritesExceptionCNAME(t *testing.T) {
 	d := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Wildcard and exception for a sub-domain.
-	d.Rewrites = []RewriteEntry{{
+	d.Rewrites = []*LegacyRewrite{{
 		Domain: "*.host.com",
 		Answer: "2.2.2.2",
 	}, {
@@ -229,7 +248,8 @@ func TestRewritesExceptionCNAME(t *testing.T) {
 		Domain: "*.sub.host.com",
 		Answer: "*.sub.host.com",
 	}}
-	d.prepareRewrites()
+
+	require.NoError(t, d.prepareRewrites())
 
 	testCases := []struct {
 		name string
@@ -253,7 +273,7 @@ func TestRewritesExceptionCNAME(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			r := d.processRewrites(tc.host, dns.TypeA)
 			if tc.want == nil {
-				assert.Equal(t, NotFilteredNotFound, r.Reason)
+				assert.Equal(t, NotFilteredNotFound, r.Reason, "got %s", r.Reason)
 
 				return
 			}
@@ -269,7 +289,7 @@ func TestRewritesExceptionIP(t *testing.T) {
 	d := newForTest(t, nil, nil)
 	t.Cleanup(d.Close)
 	// Exception for AAAA record.
-	d.Rewrites = []RewriteEntry{{
+	d.Rewrites = []*LegacyRewrite{{
 		Domain: "host.com",
 		Answer: "1.2.3.4",
 		Type:   dns.TypeA,
@@ -290,7 +310,8 @@ func TestRewritesExceptionIP(t *testing.T) {
 		Answer: "A",
 		Type:   dns.TypeA,
 	}}
-	d.prepareRewrites()
+
+	require.NoError(t, d.prepareRewrites())
 
 	testCases := []struct {
 		name string
diff --git a/internal/home/auth.go b/internal/home/auth.go
index 58058519..ca708f4a 100644
--- a/internal/home/auth.go
+++ b/internal/home/auth.go
@@ -424,7 +424,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var remoteAddr string
-	// realIP cannot be used here without taking TrustedProxies into accound due
+	// realIP cannot be used here without taking TrustedProxies into account due
 	// to security issues.
 	//
 	// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
@@ -528,7 +528,7 @@ func optionalAuthThird(w http.ResponseWriter, r *http.Request) (authFirst bool)
 	cookie, err := r.Cookie(sessionCookieName)
 
 	if glProcessCookie(r) {
-		log.Debug("auth: authentification was handled by GL-Inet submodule")
+		log.Debug("auth: authentication was handled by GL-Inet submodule")
 		ok = true
 	} else if err == nil {
 		r := Context.auth.checkSession(cookie.Value)
diff --git a/internal/home/clients.go b/internal/home/clients.go
index 7a478679..5643f6b0 100644
--- a/internal/home/clients.go
+++ b/internal/home/clients.go
@@ -96,7 +96,7 @@ type clientsContainer struct {
 	dnsServer *dnsforward.Server
 
 	// etcHosts contains list of rewrite rules taken from the operating system's
-	// hosts databse.
+	// hosts database.
 	etcHosts *aghnet.HostsContainer
 
 	testing bool // if TRUE, this object is used for internal tests
@@ -175,7 +175,7 @@ type clientObject struct {
 	UseGlobalBlockedServices bool `yaml:"use_global_blocked_services"`
 }
 
-// addFromConfig initializes the clients containter with objects from the
+// addFromConfig initializes the clients container with objects from the
 // configuration file.
 func (clients *clientsContainer) addFromConfig(objects []*clientObject) {
 	for _, o := range objects {
diff --git a/internal/home/config.go b/internal/home/config.go
index e5e1b14b..55d884c8 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -164,7 +164,7 @@ type tlsConfigSettings struct {
 
 // config is the global configuration structure.
 //
-// TODO(a.garipov, e.burkov): This global is afwul and must be removed.
+// TODO(a.garipov, e.burkov): This global is awful and must be removed.
 var config = &configuration{
 	BindPort:     3000,
 	BetaBindPort: 0,
diff --git a/internal/home/control.go b/internal/home/control.go
index df21e310..8234e00e 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -37,7 +37,7 @@ func appendDNSAddrs(dst []string, addrs ...net.IP) (res []string) {
 
 // appendDNSAddrsWithIfaces formats and appends all DNS addresses from src to
 // dst.  It also adds the IP addresses of all network interfaces if src contains
-// an unspecified IP addresss.
+// an unspecified IP address.
 func appendDNSAddrsWithIfaces(dst []string, src []net.IP) (res []string, err error) {
 	ifacesAdded := false
 	for _, h := range src {
diff --git a/internal/querylog/querylog.go b/internal/querylog/querylog.go
index f3de2aaf..18b52938 100644
--- a/internal/querylog/querylog.go
+++ b/internal/querylog/querylog.go
@@ -69,7 +69,7 @@ type Config struct {
 	// addresses.
 	AnonymizeClientIP bool
 
-	// Anonymizer proccesses the IP addresses to anonymize those if needed.
+	// Anonymizer processes the IP addresses to anonymize those if needed.
 	Anonymizer *aghnet.IPMut
 }
 
diff --git a/internal/querylog/searchcriterion.go b/internal/querylog/searchcriterion.go
index 6517c936..0595fd6e 100644
--- a/internal/querylog/searchcriterion.go
+++ b/internal/querylog/searchcriterion.go
@@ -85,7 +85,7 @@ func ctDomainOrClientCaseNonStrict(
 
 // quickMatch quickly checks if the line matches the given search criterion.
 // It returns false if the like doesn't match.  This method is only here for
-// optimisation purposes.
+// optimization purposes.
 func (c *searchCriterion) quickMatch(line string, findClient quickMatchClientFunc) (ok bool) {
 	switch c.criterionType {
 	case ctTerm:
diff --git a/internal/querylog/searchparams.go b/internal/querylog/searchparams.go
index eee9ec7a..f18ff561 100644
--- a/internal/querylog/searchparams.go
+++ b/internal/querylog/searchparams.go
@@ -32,7 +32,7 @@ type quickMatchClientFunc = func(clientID, ip string) (c *Client)
 
 // quickMatch quickly checks if the line matches the given search parameters.
 // It returns false if the line doesn't match.  This method is only here for
-// optimisation purposes.
+// optimization purposes.
 func (s *searchParams) quickMatch(line string, findClient quickMatchClientFunc) (ok bool) {
 	for _, c := range s.searchCriteria {
 		if !c.quickMatch(line, findClient) {