Skip to content

Commit 4d58335

Browse files
committed
fix(blocking): bound EDE reason, round-trip regex rules, simplify trie
Follow-up fixes from code review of the matched-rule block reason: - Cap EDE extra text (maxEDETextLength) so an unbounded matched rule (e.g. a long regex) can't bloat the OPT record and push the answer out of a size-limited UDP response during dns.Msg.Truncate; the full reason is still kept in the query log. - Report regex rules wrapped in their '/.../' delimiters so the reported rule round-trips to the configured entry instead of looking like a plain-string rule (raised by Copilot review). - Reuse the slices.Sorted(maps.Keys(...)) idiom in formatBlockReason and return a clean "BLOCKED[ TYPE]" when there are no matches. - Add trie.JoinTLD as the inverse of SplitTLD so the wildcard cache no longer hard-codes the trie's label separator. - Collapse parent.hasParentOf into polymorphic dispatch: removes the duplicated return and the unreachable (uncoverable) fallthrough; trie package back to 100% coverage. - Document the chained-cache one-rule-per-group (last-write-wins) choice.
1 parent e0a72c9 commit 4d58335

10 files changed

Lines changed: 114 additions & 45 deletions

cache/stringcache/chained_grouped_cache.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ func (c *ChainedGroupedCache) Contains(searchString string, groups []string) map
2323
// result is allocated lazily so the common no-match case stays allocation-free.
2424
// Ordering of matched groups is not defined here; callers that render the
2525
// result sort it (see resolver.formatBlockReason).
26+
//
27+
// If a group matches in more than one chained cache (e.g. an exact entry and
28+
// a wildcard), we keep a single rule per group: the last chained cache wins.
29+
// One representative rule per group is enough for the block reason, and the
30+
// chain order is fixed, so the choice is stable across requests.
2631
var result map[string]string
2732

2833
for _, cache := range c.caches {

cache/stringcache/in_memory_grouped_cache_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ var _ = Describe("In-Memory grouped cache", func() {
8585
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 1))
8686
Expect(cache.Contains("string1", []string{"group1"})).Should(BeEmpty())
8787
Expect(cache.Contains("string2", []string{"group1"})).
88-
Should(Equal(map[string]string{"group1": "string2"}))
88+
Should(Equal(map[string]string{"group1": "/string2/"}))
8989
Expect(cache.Contains("shouldalsomatchstring2", []string{"group1"})).
90-
Should(Equal(map[string]string{"group1": "string2"}))
90+
Should(Equal(map[string]string{"group1": "/string2/"}))
9191
})
9292
})
9393
When("Wildcard grouped cache is used", func() {

cache/stringcache/string_caches.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ func (cache regexCache) findMatch(searchString string) (string, bool) {
133133
if regex.MatchString(searchString) {
134134
log.PrefixedLog("regex_cache").Debugf("regex '%s' matched with '%s'", regex, searchString)
135135

136-
return regex.String(), true
136+
// re-wrap in the '/.../' delimiters that addEntry strips on insertion
137+
// so the reported rule matches the entry as configured by the user.
138+
return "/" + regex.String() + "/", true
137139
}
138140
}
139141

@@ -199,8 +201,10 @@ func (cache wildcardCache) findMatch(domain string) (string, bool) {
199201

200202
// labels reconstruct the stored wildcard base (normalized, with the "*."
201203
// prefix stripped on insertion); re-prepend "*." so the reported rule
202-
// matches the entry as configured by the user.
203-
rule := "*." + strings.Join(labels, ".")
204+
// matches the entry as configured by the user. trie.JoinTLD pairs with the
205+
// trie.SplitTLD this cache is built with, so the separator stays the trie's
206+
// concern rather than being hard-coded here.
207+
rule := "*." + trie.JoinTLD(labels)
204208

205209
log.PrefixedLog("wildcard_cache").Debugf("wildcard block rule '%s' matched with '%s'", rule, domain)
206210

cache/stringcache/string_caches_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ var _ = Describe("Caches", func() {
138138
It("should match if one regex in StringCache matches string and return the pattern", func() {
139139
rule, ok := cache.findMatch("google.com")
140140
Expect(ok).Should(BeTrue())
141-
Expect(rule).Should(Equal(".*google.com"))
141+
Expect(rule).Should(Equal("/.*google.com/"))
142142

143143
_, ok = cache.findMatch("google.coma")
144144
Expect(ok).Should(BeTrue())
@@ -149,7 +149,7 @@ var _ = Describe("Caches", func() {
149149

150150
rule, ok = cache.findMatch("apple.com")
151151
Expect(ok).Should(BeTrue())
152-
Expect(rule).Should(Equal("^apple\\.(de|com)$"))
152+
Expect(rule).Should(Equal("/^apple\\.(de|com)$/"))
153153

154154
_, ok = cache.findMatch("apple.de")
155155
Expect(ok).Should(BeTrue())
@@ -162,7 +162,7 @@ var _ = Describe("Caches", func() {
162162

163163
rule, ok = cache.findMatch("www.amazon.com")
164164
Expect(ok).Should(BeTrue())
165-
Expect(rule).Should(Equal("amazon"))
165+
Expect(rule).Should(Equal("/amazon/"))
166166

167167
_, ok = cache.findMatch("amazon.com")
168168
Expect(ok).Should(BeTrue())

resolver/blocking_resolver.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -571,21 +571,18 @@ func (r *BlockingResolver) matches(groupsToCheck []string, m lists.Matcher,
571571
// "BLOCKED[ TYPE] (group: rule[, group: rule...])". Matched groups are sorted
572572
// so the rendered reason is deterministic when more than one group matches.
573573
func formatBlockReason(matches map[string]string, typeName string) string {
574-
groups := make([]string, 0, len(matches))
575-
for group := range matches {
576-
groups = append(groups, group)
574+
reason := "BLOCKED"
575+
if typeName != "" {
576+
reason += " " + typeName
577577
}
578578

579-
sort.Strings(groups)
580-
581-
entries := make([]string, 0, len(groups))
582-
for _, group := range groups {
583-
entries = append(entries, fmt.Sprintf("%s: %s", group, matches[group]))
579+
if len(matches) == 0 {
580+
return reason
584581
}
585582

586-
reason := "BLOCKED"
587-
if typeName != "" {
588-
reason += " " + typeName
583+
entries := make([]string, 0, len(matches))
584+
for _, group := range slices.Sorted(maps.Keys(matches)) {
585+
entries = append(entries, fmt.Sprintf("%s: %s", group, matches[group]))
589586
}
590587

591588
return fmt.Sprintf("%s (%s)", reason, strings.Join(entries, ", "))

resolver/ede_resolver.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@ package resolver
22

33
import (
44
"context"
5+
"unicode/utf8"
56

67
"github.com/0xERR0R/blocky/config"
78
"github.com/0xERR0R/blocky/model"
89
"github.com/0xERR0R/blocky/util"
910
"github.com/miekg/dns"
1011
)
1112

13+
// maxEDETextLength bounds the EDE extra text. The reason now embeds the matched
14+
// denylist rule, which is unbounded for regex entries. dns.Msg.Truncate keeps
15+
// the OPT record and subtracts its full length from the size budget, so an
16+
// oversized extra text would shrink (or eliminate) the room left for the actual
17+
// answer on a size-limited (typically UDP) response. Bounding it here keeps the
18+
// OPT small; the full reason is still recorded in the query log.
19+
const maxEDETextLength = 200
20+
1221
// A EDEResolver is responsible for adding the reason for the response as EDNS0 option
1322
type EDEResolver struct {
1423
configurable[*config.EDE]
@@ -53,7 +62,22 @@ func (r *EDEResolver) addExtraReasoning(res *model.Response) {
5362

5463
edeOption := new(dns.EDNS0_EDE)
5564
edeOption.InfoCode = infocode
56-
edeOption.ExtraText = res.Reason
65+
edeOption.ExtraText = truncateText(res.Reason, maxEDETextLength)
5766

5867
util.SetEdns0Option(res.Res, edeOption)
5968
}
69+
70+
// truncateText limits s to at most maxLen bytes, cutting on a UTF-8 rune
71+
// boundary so it never emits an invalid (partial) rune.
72+
func truncateText(s string, maxLen int) string {
73+
if len(s) <= maxLen {
74+
return s
75+
}
76+
77+
cut := maxLen
78+
for cut > 0 && !utf8.RuneStart(s[cut]) {
79+
cut--
80+
}
81+
82+
return s[:cut]
83+
}

resolver/ede_resolver_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"math"
8+
"strings"
89

910
"github.com/0xERR0R/blocky/config"
1011
. "github.com/0xERR0R/blocky/helpertest"
@@ -135,6 +136,30 @@ var _ = Describe("EdeResolver", func() {
135136
})
136137
})
137138

139+
When("resolver returns a blocked response with an oversized reason", func() {
140+
longReason := "BLOCKED (ads: /" + strings.Repeat("a", 500) + "/)"
141+
142+
BeforeEach(func() {
143+
m = &mockResolver{}
144+
m.On("Resolve", mock.Anything).Return(&Response{
145+
Res: mockAnswer,
146+
RType: ResponseTypeBLOCKED,
147+
Reason: longReason,
148+
}, nil)
149+
})
150+
151+
It("caps the EDE extra text so it can't bloat the OPT record", func() {
152+
Expect(sut.Resolve(ctx, newRequest("example.com.", A))).
153+
Should(
154+
SatisfyAll(
155+
HaveEdnsOption(dns.EDNS0EDE),
156+
WithTransform(extractEdeOption,
157+
WithTransform(func(o dns.EDNS0_EDE) int { return len(o.ExtraText) },
158+
BeNumerically("<=", maxEDETextLength))),
159+
))
160+
})
161+
})
162+
138163
When("resolver returns other", func() {
139164
BeforeEach(func() {
140165
m = &mockResolver{}

trie/split.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ func SplitTLD(domain string) (label, rest string) {
1818

1919
return label, rest
2020
}
21+
22+
// JoinTLD is the inverse of SplitTLD: it reconstructs an entry from the labels
23+
// returned by HasParentOf on a trie built with SplitTLD.
24+
// ["example", "com"] -> "example.com"
25+
func JoinTLD(labels []string) string {
26+
return strings.Join(labels, ".")
27+
}

trie/split_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,22 @@ var _ = Describe("SpltTLD", func() {
4444
Expect(rest).Should(Equal("www"))
4545
})
4646
})
47+
48+
var _ = Describe("JoinTLD", func() {
49+
It("reconstructs an entry from labels in entry order", func() {
50+
Expect(JoinTLD([]string{"example", "com"})).Should(Equal("example.com"))
51+
})
52+
53+
It("handles a single label", func() {
54+
Expect(JoinTLD([]string{"blocked"})).Should(Equal("blocked"))
55+
})
56+
57+
It("reproduces the labels returned by HasParentOf", func() {
58+
sut := NewTrie(SplitTLD)
59+
sut.Insert("sub.example.com")
60+
61+
labels, ok := sut.HasParentOf("a.sub.example.com")
62+
Expect(ok).Should(BeTrue())
63+
Expect(JoinTLD(labels)).Should(Equal("sub.example.com"))
64+
})
65+
})

trie/trie.go

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -122,34 +122,22 @@ func (n *parent) hasParentOf(key string, split SplitFunc) ([]string, bool) {
122122
return nil, false
123123
}
124124

125-
switch child := child.(type) {
126-
case *parent:
127-
if len(rest) == 0 {
128-
// The trie only contains children/"suffixes" of the
129-
// key we're searching for
130-
return nil, false
131-
}
132-
133-
labels, ok := child.hasParentOf(rest, split)
134-
if !ok {
135-
return nil, false
136-
}
137-
138-
// On the matching path only: prepend-by-append this node's label.
139-
// The deeper labels were collected first, so appending the current
140-
// (more significant) label keeps the entry's natural order.
141-
return append(labels, label), true
142-
143-
case terminal:
144-
labels, ok := child.hasParentOf(rest, split)
145-
if !ok {
146-
return nil, false
147-
}
125+
// A *parent child means the trie only stores longer entries/"suffixes" below
126+
// this node; if the search key has no more labels, none of them can be a
127+
// parent of it.
128+
if _, isParent := child.(*parent); isParent && len(rest) == 0 {
129+
return nil, false
130+
}
148131

149-
return append(labels, label), true
132+
labels, ok := child.hasParentOf(rest, split)
133+
if !ok {
134+
return nil, false
150135
}
151136

152-
return nil, false
137+
// On the matching path only: prepend-by-append this node's label. The deeper
138+
// labels were collected first, so appending the current (more significant)
139+
// label keeps the entry's natural order.
140+
return append(labels, label), true
153141
}
154142

155143
type terminal string

0 commit comments

Comments
 (0)