diff --git a/internal/filtering/rewrite/item.go b/internal/filtering/rewrite/item.go
index e5693143..d67798d7 100644
--- a/internal/filtering/rewrite/item.go
+++ b/internal/filtering/rewrite/item.go
@@ -2,7 +2,7 @@ package rewrite
 
 import (
 	"fmt"
-	"net"
+	"net/netip"
 	"strings"
 
 	"github.com/miekg/dns"
@@ -26,11 +26,15 @@ func (rw *Item) equal(other *Item) (ok bool) {
 		return false
 	}
 
-	return rw.Domain == other.Domain && rw.Answer == other.Answer
+	return *rw == *other
 }
 
 // toRule converts rw to a filter rule.
 func (rw *Item) toRule() (res string) {
+	if rw == nil {
+		return ""
+	}
+
 	domain := strings.ToLower(rw.Domain)
 
 	dType, exception := rw.rewriteParams()
@@ -53,13 +57,13 @@ func (rw *Item) rewriteParams() (dType uint16, exception bool) {
 		// Go on.
 	}
 
-	ip := net.ParseIP(rw.Answer)
-	if ip == nil {
+	addr, err := netip.ParseAddr(rw.Answer)
+	if err != nil {
+		// TODO(d.kolyshev): Validate rw.Answer as a domain name.
 		return dns.TypeCNAME, false
 	}
 
-	ip4 := ip.To4()
-	if ip4 != nil {
+	if addr.Is4() {
 		dType = dns.TypeA
 	} else {
 		dType = dns.TypeAAAA
diff --git a/internal/filtering/rewrite/item_internal_test.go b/internal/filtering/rewrite/item_internal_test.go
new file mode 100644
index 00000000..68d88223
--- /dev/null
+++ b/internal/filtering/rewrite/item_internal_test.go
@@ -0,0 +1,124 @@
+package rewrite
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestItem_equal(t *testing.T) {
+	const (
+		testDomain = "example.org"
+		testAnswer = "1.1.1.1"
+	)
+
+	testItem := &Item{
+		Domain: testDomain,
+		Answer: testAnswer,
+	}
+
+	testCases := []struct {
+		name  string
+		left  *Item
+		right *Item
+		want  bool
+	}{{
+		name:  "nil_left",
+		left:  nil,
+		right: testItem,
+		want:  false,
+	}, {
+		name:  "nil_right",
+		left:  testItem,
+		right: nil,
+		want:  false,
+	}, {
+		name:  "nils",
+		left:  nil,
+		right: nil,
+		want:  true,
+	}, {
+		name:  "equal",
+		left:  testItem,
+		right: testItem,
+		want:  true,
+	}, {
+		name: "distinct",
+		left: testItem,
+		right: &Item{
+			Domain: "other",
+			Answer: "other",
+		},
+		want: false,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			res := tc.left.equal(tc.right)
+			assert.Equal(t, tc.want, res)
+		})
+	}
+}
+
+func TestItem_toRule(t *testing.T) {
+	const testDomain = "example.org"
+
+	testCases := []struct {
+		name string
+		item *Item
+		want string
+	}{{
+		name: "nil",
+		item: nil,
+		want: "",
+	}, {
+		name: "a_rule",
+		item: &Item{
+			Domain: testDomain,
+			Answer: "1.1.1.1",
+		},
+		want: "|example.org^$dnsrewrite=NOERROR;A;1.1.1.1",
+	}, {
+		name: "aaaa_rule",
+		item: &Item{
+			Domain: testDomain,
+			Answer: "1:2:3::4",
+		},
+		want: "|example.org^$dnsrewrite=NOERROR;AAAA;1:2:3::4",
+	}, {
+		name: "cname_rule",
+		item: &Item{
+			Domain: testDomain,
+			Answer: "other.org",
+		},
+		want: "|example.org^$dnsrewrite=NOERROR;CNAME;other.org",
+	}, {
+		name: "wildcard_rule",
+		item: &Item{
+			Domain: "*.example.org",
+			Answer: "other.org",
+		},
+		want: "|*.example.org^$dnsrewrite=NOERROR;CNAME;other.org",
+	}, {
+		name: "aaaa_exception",
+		item: &Item{
+			Domain: testDomain,
+			Answer: "A",
+		},
+		want: "@@||example.org^$dnstype=A,dnsrewrite",
+	}, {
+		name: "aaaa_exception",
+		item: &Item{
+			Domain: testDomain,
+			Answer: "AAAA",
+		},
+		want: "@@||example.org^$dnstype=AAAA,dnsrewrite",
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			res := tc.item.toRule()
+			assert.Equal(t, tc.want, res)
+		})
+	}
+}
diff --git a/internal/filtering/rewrite/storage.go b/internal/filtering/rewrite/storage.go
index 77355db5..0afb8b6e 100644
--- a/internal/filtering/rewrite/storage.go
+++ b/internal/filtering/rewrite/storage.go
@@ -7,15 +7,18 @@ import (
 	"sync"
 
 	"github.com/AdguardTeam/golibs/log"
+	"github.com/AdguardTeam/golibs/stringutil"
 	"github.com/AdguardTeam/urlfilter"
 	"github.com/AdguardTeam/urlfilter/filterlist"
+	"github.com/AdguardTeam/urlfilter/rules"
+	"github.com/miekg/dns"
 	"golang.org/x/exp/slices"
 )
 
 // Storage is a storage for rewrite rules.
 type Storage interface {
-	// MatchRequest finds a matching rule for the specified request.
-	MatchRequest(dReq *urlfilter.DNSRequest) (res *urlfilter.DNSResult, matched bool)
+	// MatchRequest returns matching dnsrewrites for the specified request.
+	MatchRequest(dReq *urlfilter.DNSRequest) (rws []*rules.DNSRewrite)
 
 	// Add adds item to the storage.
 	Add(item *Item) (err error)
@@ -38,14 +41,14 @@ type DefaultStorage struct {
 	// ruleList is the filtering rule ruleList used by the engine.
 	ruleList filterlist.RuleList
 
+	// rewrites stores the rewrite entries from configuration.
+	rewrites []*Item
+
 	// urlFilterID is the synthetic integer identifier for the urlfilter engine.
 	//
 	// TODO(a.garipov): Change the type to a string in module urlfilter and
 	// remove this crutch.
 	urlFilterID int
-
-	// rewrites stores the rewrite entries from configuration.
-	rewrites []*Item
 }
 
 // NewDefaultStorage returns new rewrites storage.  listID is used as an
@@ -72,11 +75,81 @@ func NewDefaultStorage(listID int, rewrites []*Item) (s *DefaultStorage, err err
 var _ Storage = (*DefaultStorage)(nil)
 
 // MatchRequest implements the [Storage] interface for *DefaultStorage.
-func (s *DefaultStorage) MatchRequest(dReq *urlfilter.DNSRequest) (res *urlfilter.DNSResult, matched bool) {
+func (s *DefaultStorage) MatchRequest(dReq *urlfilter.DNSRequest) (rws []*rules.DNSRewrite) {
 	s.mu.RLock()
 	defer s.mu.RUnlock()
 
-	return s.engine.MatchRequest(dReq)
+	rrules := s.rewriteRulesForReq(dReq)
+	if len(rrules) == 0 {
+		return nil
+	}
+
+	// TODO(a.garipov): Check cnames for cycles on initialisation.
+	cnames := stringutil.NewSet()
+	host := dReq.Hostname
+	for len(rrules) > 0 && rrules[0].DNSRewrite != nil && rrules[0].DNSRewrite.NewCNAME != "" {
+		rule := rrules[0]
+		rwAns := rule.DNSRewrite.NewCNAME
+
+		log.Debug("rewrite: cname for %s is %s", host, rwAns)
+
+		if dReq.Hostname == rwAns {
+			// A request for the hostname itself is an exception rule.
+			// TODO(d.kolyshev): Check rewrite of a pattern onto itself.
+
+			return nil
+		}
+
+		if host == rwAns && isWildcard(rule.RuleText) {
+			// An "*.example.com → sub.example.com" rewrite matching in a loop.
+			//
+			// See https://github.com/AdguardTeam/AdGuardHome/issues/4016.
+
+			return []*rules.DNSRewrite{rule.DNSRewrite}
+		}
+
+		if cnames.Has(rwAns) {
+			log.Info("rewrite: cname loop for %q on %q", dReq.Hostname, rwAns)
+
+			return nil
+		}
+
+		cnames.Add(rwAns)
+
+		drules := s.rewriteRulesForReq(&urlfilter.DNSRequest{
+			Hostname: rwAns,
+			DNSType:  dReq.DNSType,
+		})
+		if drules != nil {
+			rrules = drules
+		}
+
+		host = rwAns
+	}
+
+	return s.collectDNSRewrites(rrules, dReq.DNSType)
+}
+
+// collectDNSRewrites filters DNSRewrite by question type.
+func (s *DefaultStorage) collectDNSRewrites(
+	rewrites []*rules.NetworkRule,
+	qtyp uint16,
+) (rws []*rules.DNSRewrite) {
+	for _, rewrite := range rewrites {
+		dnsRewrite := rewrite.DNSRewrite
+		if matchesQType(dnsRewrite, qtyp) {
+			rws = append(rws, dnsRewrite)
+		}
+	}
+
+	return rws
+}
+
+// rewriteRulesForReq returns matching dnsrewrite rules.
+func (s *DefaultStorage) rewriteRulesForReq(dReq *urlfilter.DNSRequest) (rules []*rules.NetworkRule) {
+	res, _ := s.engine.MatchRequest(dReq)
+
+	return res.DNSRewrites()
 }
 
 // Add implements the [Storage] interface for *DefaultStorage.
@@ -122,6 +195,7 @@ func (s *DefaultStorage) List() (items []*Item) {
 
 // resetRules resets the filtering rules.
 func (s *DefaultStorage) resetRules() (err error) {
+	// TODO(a.garipov): Use strings.Builder.
 	var rulesText []string
 	for _, rewrite := range s.rewrites {
 		rulesText = append(rulesText, rewrite.toRule())
@@ -141,7 +215,27 @@ func (s *DefaultStorage) resetRules() (err error) {
 	s.ruleList = strList
 	s.engine = urlfilter.NewDNSEngine(rs)
 
-	log.Info("filter %d: reset %d rules", s.urlFilterID, s.engine.RulesCount)
+	log.Info("rewrite: filter %d: reset %d rules", s.urlFilterID, s.engine.RulesCount)
 
 	return nil
 }
+
+// matchesQType returns true if dnsrewrite matches the question type qt.
+func matchesQType(dnsrr *rules.DNSRewrite, qt uint16) (ok bool) {
+	// Add CNAMEs, since they match for all types requests.
+	if dnsrr.RRType == dns.TypeCNAME {
+		return true
+	}
+
+	// Reject types other than A and AAAA.
+	if qt != dns.TypeA && qt != dns.TypeAAAA {
+		return false
+	}
+
+	return dnsrr.RRType == qt
+}
+
+// isWildcard returns true if pat is a wildcard domain pattern.
+func isWildcard(pat string) (res bool) {
+	return strings.HasPrefix(pat, "|*.")
+}
diff --git a/internal/filtering/rewrite/storage_test.go b/internal/filtering/rewrite/storage_test.go
index c240cca0..4db06a4e 100644
--- a/internal/filtering/rewrite/storage_test.go
+++ b/internal/filtering/rewrite/storage_test.go
@@ -1,8 +1,13 @@
 package rewrite
 
 import (
+	"net"
 	"testing"
 
+	"github.com/AdguardTeam/urlfilter"
+	"github.com/AdguardTeam/urlfilter/rules"
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -38,3 +43,416 @@ func TestDefaultStorage_CRUD(t *testing.T) {
 	require.NoError(t, err)
 	require.Len(t, s.List(), 0)
 }
+
+func TestDefaultStorage_MatchRequest(t *testing.T) {
+	items := []*Item{{
+		// This one and below are about CNAME, A and AAAA.
+		Domain: "somecname",
+		Answer: "somehost.com",
+	}, {
+		Domain: "somehost.com",
+		Answer: "0.0.0.0",
+	}, {
+		Domain: "host.com",
+		Answer: "1.2.3.4",
+	}, {
+		Domain: "host.com",
+		Answer: "1.2.3.5",
+	}, {
+		Domain: "host.com",
+		Answer: "1:2:3::4",
+	}, {
+		Domain: "www.host.com",
+		Answer: "host.com",
+	}, {
+		// This one is a wildcard.
+		Domain: "*.host.com",
+		Answer: "1.2.3.5",
+	}, {
+		// This one and below are about wildcard overriding.
+		Domain: "a.host.com",
+		Answer: "1.2.3.4",
+	}, {
+		// This one is about CNAME and wildcard interacting.
+		Domain: "*.host2.com",
+		Answer: "host.com",
+	}, {
+		// This one and below are about 2 level CNAME.
+		Domain: "b.host.com",
+		Answer: "somecname",
+	}, {
+		// This one and below are about 2 level CNAME and wildcard.
+		Domain: "b.host3.com",
+		Answer: "a.host3.com",
+	}, {
+		Domain: "a.host3.com",
+		Answer: "x.host.com",
+	}, {
+		Domain: "*.hostboth.com",
+		Answer: "1.2.3.6",
+	}, {
+		Domain: "*.hostboth.com",
+		Answer: "1234::5678",
+	}, {
+		Domain: "BIGHOST.COM",
+		Answer: "1.2.3.7",
+	}, {
+		Domain: "*.issue4016.com",
+		Answer: "sub.issue4016.com",
+	}}
+
+	s, err := NewDefaultStorage(-1, items)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name            string
+		host            string
+		wantDNSRewrites []*rules.DNSRewrite
+		dtyp            uint16
+	}{{
+		name:            "not_filtered_not_found",
+		host:            "hoost.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeA,
+	}, {
+		name:            "not_filtered_qtype",
+		host:            "www.host.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeMX,
+	}, {
+		name: "rewritten_a",
+		host: "www.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 4}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}, {
+			Value:    net.IP{1, 2, 3, 5}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name: "rewritten_aaaa",
+		host: "www.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.ParseIP("1:2:3::4"),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeAAAA,
+		}},
+		dtyp: dns.TypeAAAA,
+	}, {
+		name: "wildcard_match",
+		host: "abc.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 5}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+		//}, {
+		// TODO(d.kolyshev): This is about matching in urlfilter.
+		//	name: "wildcard_override",
+		//	host: "a.host.com",
+		//	wantDNSRewrites: []*rules.DNSRewrite{{
+		//		Value:    net.IP{1, 2, 3, 4}.To16(),
+		//		NewCNAME: "",
+		//		RCode:    dns.RcodeSuccess,
+		//		RRType:   dns.TypeA,
+		//	}},
+		//	dtyp: dns.TypeA,
+	}, {
+		name: "wildcard_cname_interaction",
+		host: "www.host2.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 4}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}, {
+			Value:    net.IP{1, 2, 3, 5}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name: "two_cnames",
+		host: "b.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{0, 0, 0, 0}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name: "two_cnames_and_wildcard",
+		host: "b.host3.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 5}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name: "issue3343",
+		host: "www.hostboth.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.ParseIP("1234::5678"),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeAAAA,
+		}},
+		dtyp: dns.TypeAAAA,
+	}, {
+		name: "issue3351",
+		host: "bighost.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 7}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name:            "issue4008",
+		host:            "somehost.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeHTTPS,
+	}, {
+		name: "issue4016",
+		host: "www.issue4016.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    nil,
+			NewCNAME: "sub.issue4016.com",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeNone,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name:            "issue4016_self",
+		host:            "sub.issue4016.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeA,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			dnsRewrites := s.MatchRequest(&urlfilter.DNSRequest{
+				Hostname: tc.host,
+				DNSType:  tc.dtyp,
+			})
+
+			assert.Equal(t, tc.wantDNSRewrites, dnsRewrites)
+		})
+	}
+}
+
+func TestDefaultStorage_MatchRequest_Levels(t *testing.T) {
+	// Exact host, wildcard L2, wildcard L3.
+	items := []*Item{{
+		Domain: "host.com",
+		Answer: "1.1.1.1",
+	}, {
+		Domain: "*.host.com",
+		Answer: "2.2.2.2",
+	}, {
+		Domain: "*.sub.host.com",
+		Answer: "3.3.3.3",
+	}}
+
+	s, err := NewDefaultStorage(-1, items)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name            string
+		host            string
+		wantDNSRewrites []*rules.DNSRewrite
+		dtyp            uint16
+	}{{
+		name: "exact_match",
+		host: "host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 1, 1, 1}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name: "l2_match",
+		host: "sub.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{2, 2, 2, 2}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+		//}, {
+		// TODO(d.kolyshev): This is about matching in urlfilter.
+		//	name: "l3_match",
+		//	host: "my.sub.host.com",
+		//	wantDNSRewrites: []*rules.DNSRewrite{{
+		//		Value:    net.IP{3, 3, 3, 3}.To16(),
+		//		NewCNAME: "",
+		//		RCode:    dns.RcodeSuccess,
+		//		RRType:   dns.TypeA,
+		//	}},
+		//	dtyp: dns.TypeA,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			dnsRewrites := s.MatchRequest(&urlfilter.DNSRequest{
+				Hostname: tc.host,
+				DNSType:  tc.dtyp,
+			})
+
+			assert.Equal(t, tc.wantDNSRewrites, dnsRewrites)
+		})
+	}
+}
+
+func TestDefaultStorage_MatchRequest_ExceptionCNAME(t *testing.T) {
+	// Wildcard and exception for a sub-domain.
+	items := []*Item{{
+		Domain: "*.host.com",
+		Answer: "2.2.2.2",
+	}, {
+		Domain: "sub.host.com",
+		Answer: "sub.host.com",
+	}, {
+		Domain: "*.sub.host.com",
+		Answer: "*.sub.host.com",
+	}}
+
+	s, err := NewDefaultStorage(-1, items)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name            string
+		host            string
+		wantDNSRewrites []*rules.DNSRewrite
+		dtyp            uint16
+	}{{
+		name: "match_subdomain",
+		host: "my.host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{2, 2, 2, 2}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name:            "exception_cname",
+		host:            "sub.host.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeA,
+		//}, {
+		// TODO(d.kolyshev): This is about matching in urlfilter.
+		//	name:            "exception_wildcard",
+		//	host:            "my.sub.host.com",
+		//	wantDNSRewrites: nil,
+		//	dtyp:            dns.TypeA,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			dnsRewrites := s.MatchRequest(&urlfilter.DNSRequest{
+				Hostname: tc.host,
+				DNSType:  tc.dtyp,
+			})
+
+			assert.Equal(t, tc.wantDNSRewrites, dnsRewrites)
+		})
+	}
+}
+
+func TestDefaultStorage_MatchRequest_ExceptionIP(t *testing.T) {
+	// Exception for AAAA record.
+	items := []*Item{{
+		Domain: "host.com",
+		Answer: "1.2.3.4",
+	}, {
+		Domain: "host.com",
+		Answer: "AAAA",
+	}, {
+		Domain: "host2.com",
+		Answer: "::1",
+	}, {
+		Domain: "host2.com",
+		Answer: "A",
+	}, {
+		Domain: "host3.com",
+		Answer: "A",
+	}}
+
+	s, err := NewDefaultStorage(-1, items)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name            string
+		host            string
+		wantDNSRewrites []*rules.DNSRewrite
+		dtyp            uint16
+	}{{
+		name: "match_A",
+		host: "host.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.IP{1, 2, 3, 4}.To16(),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeA,
+		}},
+		dtyp: dns.TypeA,
+	}, {
+		name:            "exception_AAAA_host.com",
+		host:            "host.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeAAAA,
+	}, {
+		name:            "exception_A_host2.com",
+		host:            "host2.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeA,
+	}, {
+		name: "match_AAAA_host2.com",
+		host: "host2.com",
+		wantDNSRewrites: []*rules.DNSRewrite{{
+			Value:    net.ParseIP("::1"),
+			NewCNAME: "",
+			RCode:    dns.RcodeSuccess,
+			RRType:   dns.TypeAAAA,
+		}},
+		dtyp: dns.TypeAAAA,
+	}, {
+		name:            "exception_A_host3.com",
+		host:            "host3.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeA,
+	}, {
+		name:            "match_AAAA_host3.com",
+		host:            "host3.com",
+		wantDNSRewrites: nil,
+		dtyp:            dns.TypeAAAA,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			dnsRewrites := s.MatchRequest(&urlfilter.DNSRequest{
+				Hostname: tc.host,
+				DNSType:  tc.dtyp,
+			})
+
+			assert.Equal(t, tc.wantDNSRewrites, dnsRewrites)
+		})
+	}
+}
diff --git a/internal/filtering/rewritehttp.go b/internal/filtering/rewritehttp.go
new file mode 100644
index 00000000..752979fe
--- /dev/null
+++ b/internal/filtering/rewritehttp.go
@@ -0,0 +1,93 @@
+package filtering
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
+	"github.com/AdguardTeam/golibs/log"
+)
+
+// TODO(d.kolyshev): Use [rewrite.Item] instead.
+type rewriteEntryJSON struct {
+	Domain string `json:"domain"`
+	Answer string `json:"answer"`
+}
+
+func (d *DNSFilter) handleRewriteList(w http.ResponseWriter, r *http.Request) {
+	arr := []*rewriteEntryJSON{}
+
+	d.confLock.Lock()
+	for _, ent := range d.Config.Rewrites {
+		jsent := rewriteEntryJSON{
+			Domain: ent.Domain,
+			Answer: ent.Answer,
+		}
+		arr = append(arr, &jsent)
+	}
+	d.confLock.Unlock()
+
+	_ = aghhttp.WriteJSONResponse(w, r, arr)
+}
+
+func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) {
+	rwJSON := rewriteEntryJSON{}
+	err := json.NewDecoder(r.Body).Decode(&rwJSON)
+	if err != nil {
+		aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
+
+		return
+	}
+
+	rw := &LegacyRewrite{
+		Domain: rwJSON.Domain,
+		Answer: rwJSON.Answer,
+	}
+
+	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, rw)
+	d.confLock.Unlock()
+	log.Debug("rewrite: added element: %s -> %s [%d]", rw.Domain, rw.Answer, len(d.Config.Rewrites))
+
+	d.Config.ConfigModified()
+}
+
+func (d *DNSFilter) handleRewriteDelete(w http.ResponseWriter, r *http.Request) {
+	jsent := rewriteEntryJSON{}
+	err := json.NewDecoder(r.Body).Decode(&jsent)
+	if err != nil {
+		aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
+
+		return
+	}
+
+	entDel := &LegacyRewrite{
+		Domain: jsent.Domain,
+		Answer: jsent.Answer,
+	}
+	arr := []*LegacyRewrite{}
+
+	d.confLock.Lock()
+	for _, ent := range d.Config.Rewrites {
+		if ent.equal(entDel) {
+			log.Debug("rewrite: removed element: %s -> %s", ent.Domain, ent.Answer)
+
+			continue
+		}
+
+		arr = append(arr, ent)
+	}
+	d.Config.Rewrites = arr
+	d.confLock.Unlock()
+
+	d.Config.ConfigModified()
+}
diff --git a/internal/filtering/rewrites.go b/internal/filtering/rewrites.go
index d5033900..057a9c4e 100644
--- a/internal/filtering/rewrites.go
+++ b/internal/filtering/rewrites.go
@@ -3,22 +3,16 @@
 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/miekg/dns"
 	"golang.org/x/exp/slices"
 )
 
-// TODO(d.kolyshev): Rename this file to rewritehttp.go.
-
 // LegacyRewrite is a single legacy DNS rewrite record.
 //
 // Instances of *LegacyRewrite must never be nil.
@@ -223,86 +217,3 @@ func max(a, b int) int {
 
 	return b
 }
-
-type rewriteEntryJSON struct {
-	Domain string `json:"domain"`
-	Answer string `json:"answer"`
-}
-
-func (d *DNSFilter) handleRewriteList(w http.ResponseWriter, r *http.Request) {
-	arr := []*rewriteEntryJSON{}
-
-	d.confLock.Lock()
-	for _, ent := range d.Config.Rewrites {
-		jsent := rewriteEntryJSON{
-			Domain: ent.Domain,
-			Answer: ent.Answer,
-		}
-		arr = append(arr, &jsent)
-	}
-	d.confLock.Unlock()
-
-	_ = aghhttp.WriteJSONResponse(w, r, arr)
-}
-
-func (d *DNSFilter) handleRewriteAdd(w http.ResponseWriter, r *http.Request) {
-	rwJSON := rewriteEntryJSON{}
-	err := json.NewDecoder(r.Body).Decode(&rwJSON)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
-
-		return
-	}
-
-	rw := &LegacyRewrite{
-		Domain: rwJSON.Domain,
-		Answer: rwJSON.Answer,
-	}
-
-	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, rw)
-	d.confLock.Unlock()
-	log.Debug("rewrite: added element: %s -> %s [%d]", rw.Domain, rw.Answer, len(d.Config.Rewrites))
-
-	d.Config.ConfigModified()
-}
-
-func (d *DNSFilter) handleRewriteDelete(w http.ResponseWriter, r *http.Request) {
-	jsent := rewriteEntryJSON{}
-	err := json.NewDecoder(r.Body).Decode(&jsent)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err)
-
-		return
-	}
-
-	entDel := &LegacyRewrite{
-		Domain: jsent.Domain,
-		Answer: jsent.Answer,
-	}
-	arr := []*LegacyRewrite{}
-
-	d.confLock.Lock()
-	for _, ent := range d.Config.Rewrites {
-		if ent.equal(entDel) {
-			log.Debug("rewrite: removed element: %s -> %s", ent.Domain, ent.Answer)
-
-			continue
-		}
-
-		arr = append(arr, ent)
-	}
-	d.Config.Rewrites = arr
-	d.confLock.Unlock()
-
-	d.Config.ConfigModified()
-}
diff --git a/internal/filtering/rewrites_test.go b/internal/filtering/rewrites_test.go
index 629df5cc..17caa167 100644
--- a/internal/filtering/rewrites_test.go
+++ b/internal/filtering/rewrites_test.go
@@ -10,7 +10,6 @@ import (
 )
 
 // TODO(e.burkov): All the tests in this file may and should me merged together.
-// TODO(d.kolyshev): Move these tests to rewrite package.
 
 func TestRewrites(t *testing.T) {
 	d, _ := newForTest(t, nil, nil)
diff --git a/scripts/make/go-lint.sh b/scripts/make/go-lint.sh
index 31aba8c3..245211be 100644
--- a/scripts/make/go-lint.sh
+++ b/scripts/make/go-lint.sh
@@ -227,9 +227,9 @@ gocyclo --over 13 ./internal/dhcpd ./internal/filtering/ ./internal/home/
 
 # Apply stricter standards to new or somewhat refactored code.
 gocyclo --over 10 ./internal/aghio/ ./internal/aghnet/ ./internal/aghos/\
-	./internal/aghtest/ ./internal/dnsforward/ ./internal/stats/\
-	./internal/tools/ ./internal/updater/ ./internal/next/ ./internal/version/\
-	./scripts/vetted-filters/ ./main.go
+	./internal/aghtest/ ./internal/dnsforward/ ./internal/filtering/rewrite/\
+  ./internal/stats/ ./internal/tools/ ./internal/updater/ ./internal/next/\
+  ./internal/version/ ./scripts/vetted-filters/ ./main.go
 
 ineffassign ./...