Skip to content

Commit 2d2a5df

Browse files
committed
Import name_list.go
1 parent 252c44c commit 2d2a5df

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

‎dnscrypt-proxy/name_list.go‎

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"time"
8+
"unicode"
9+
"unicode/utf8"
10+
11+
"github.com/jedisct1/dlog"
12+
revera "github.com/oneregex/revera/go"
13+
)
14+
15+
const (
16+
regexKeyword = "regex:"
17+
18+
// Longest name regex rules are checked against: a full-size DNS name with
19+
// every byte written as \DDD.
20+
regexMaxSubjectLen = 1003
21+
22+
// Maximum cost of all the regex rules of a list, in Revera steps.
23+
// A query checks the allowlist once and the blocklist up to 1+aliasesLimit
24+
// times, so this also limits the cost of a query.
25+
regexMaxSteps int64 = 250_000_000
26+
27+
regexMaxExpressionLen = 1024
28+
regexMaxRules = 1000
29+
30+
// Revera reports this cost when it is too large to compute.
31+
reveraSaturatedSteps = int64(1) << 62
32+
)
33+
34+
// nameRegexp is a compiled expression. Tests replace it to simulate errors.
35+
type nameRegexp interface {
36+
MatchString(subject string) (bool, error)
37+
}
38+
39+
type regexRule struct {
40+
re nameRegexp
41+
directive string
42+
weeklyRanges *WeeklyRanges
43+
line int
44+
}
45+
46+
// NameList holds the rules of a blocked or allowed names file.
47+
// It never changes once loaded, so concurrent queries can share it.
48+
type NameList struct {
49+
patternMatcher *PatternMatcher
50+
regexRules []regexRule
51+
fileName string
52+
}
53+
54+
// LoadNameList parses a name list file.
55+
// Invalid ordinary rules are logged and skipped, but any problem with a regex
56+
// rule rejects the whole file.
57+
func LoadNameList(fileName string, lines string, allWeeklyRanges *map[string]WeeklyRanges) (*NameList, error) {
58+
nameList := &NameList{
59+
patternMatcher: NewPatternMatcher(),
60+
fileName: fileName,
61+
}
62+
var totalSteps int64
63+
for lineNo, line := range strings.Split(lines, "\n") {
64+
if candidate, ok := regexDirectiveCandidate(line); ok {
65+
if len(nameList.regexRules) >= regexMaxRules {
66+
return nil, fmt.Errorf("%s:%d: too many regex rules, the limit is %d per list", fileName, lineNo+1, regexMaxRules)
67+
}
68+
rule, steps, err := compileRegexRule(candidate, line, allWeeklyRanges)
69+
if err == nil {
70+
err = checkRegexCost(totalSteps, steps, regexMaxSteps)
71+
}
72+
if err != nil {
73+
return nil, fmt.Errorf("%s:%d: %w", fileName, lineNo+1, err)
74+
}
75+
totalSteps += steps
76+
rule.line = lineNo + 1
77+
nameList.regexRules = append(nameList.regexRules, rule)
78+
continue
79+
}
80+
line = TrimAndStripInlineComments(line)
81+
if len(line) == 0 {
82+
continue
83+
}
84+
rulePart, weeklyRanges, err := ParseTimeBasedRule(line, lineNo, allWeeklyRanges)
85+
if err != nil {
86+
dlog.Error(err)
87+
continue
88+
}
89+
if err := nameList.patternMatcher.Add(rulePart, weeklyRanges, lineNo+1); err != nil {
90+
dlog.Error(err)
91+
continue
92+
}
93+
}
94+
return nameList, nil
95+
}
96+
97+
// regexDirectiveCandidate reports whether a line looks like a regex rule, even
98+
// a broken one, so that it never ends up parsed as an ordinary rule.
99+
func regexDirectiveCandidate(line string) (string, bool) {
100+
trimmed := strings.TrimLeftFunc(line, unicode.IsSpace)
101+
if len(trimmed) < len(regexKeyword) || !strings.EqualFold(trimmed[:len(regexKeyword)], regexKeyword) {
102+
return "", false
103+
}
104+
return trimmed, true
105+
}
106+
107+
func compileRegexRule(candidate string, line string, allWeeklyRanges *map[string]WeeklyRanges) (regexRule, int64, error) {
108+
var rule regexRule
109+
if leading := line[:len(line)-len(candidate)]; strings.Trim(leading, " \t") != "" {
110+
return rule, 0, fmt.Errorf("only spaces and tabs can precede %q", regexKeyword)
111+
}
112+
directive, expression, weeklyRanges, err := parseRegexDirective(strings.TrimSuffix(candidate, "\r"), allWeeklyRanges)
113+
if err != nil {
114+
return rule, 0, err
115+
}
116+
re, steps, err := compileRegex(expression)
117+
if err != nil {
118+
return rule, 0, fmt.Errorf("cannot compile `%s`: %w", expression, err)
119+
}
120+
rule.re = re
121+
// Copy the text, so that the list doesn't keep the whole file in memory
122+
rule.directive = strings.Clone(directive)
123+
rule.weeklyRanges = weeklyRanges
124+
return rule, steps, nil
125+
}
126+
127+
// compileRegex compiles an expression and returns its cost for the longest names.
128+
func compileRegex(expression string) (*revera.Regexp, int64, error) {
129+
re, err := revera.New(expression, revera.CaseInsensitive(), revera.NoCaptures())
130+
if err != nil {
131+
return nil, 0, err
132+
}
133+
return re, re.Contract(regexMaxSubjectLen).Steps(), nil
134+
}
135+
136+
func checkRegexCost(totalSteps int64, steps int64, maxSteps int64) error {
137+
if steps < 0 || steps >= reveraSaturatedSteps {
138+
return errors.New("the cost of this rule is too large to compute")
139+
}
140+
if steps > maxSteps-totalSteps {
141+
return fmt.Errorf("this rule costs %d steps and brings the regex rules of the list to %d steps, over the limit of %d",
142+
steps, uint64(totalSteps)+uint64(steps), maxSteps)
143+
}
144+
return nil
145+
}
146+
147+
// parseRegexDirective parses a line such as:
148+
//
149+
// regex: `expression` @schedule # comment
150+
//
151+
// The schedule and the comment are optional.
152+
// directive is the rule as written, without them, and is what logs show.
153+
func parseRegexDirective(line string, allWeeklyRanges *map[string]WeeklyRanges) (directive string, expression string, weeklyRanges *WeeklyRanges, err error) {
154+
if !strings.HasPrefix(line, regexKeyword) {
155+
return "", "", nil, fmt.Errorf("the regex keyword must be written in lowercase as %q", regexKeyword)
156+
}
157+
rest := strings.TrimLeft(line[len(regexKeyword):], " \t")
158+
if !strings.HasPrefix(rest, "`") {
159+
return "", "", nil, fmt.Errorf("expected an expression between backticks after %q, such as regex: `^example\\.com$`", regexKeyword)
160+
}
161+
end := strings.IndexByte(rest[1:], '`')
162+
if end < 0 {
163+
return "", "", nil, errors.New("missing closing backtick")
164+
}
165+
expression = rest[1 : 1+end]
166+
directive = line[:len(line)-len(rest)+end+2]
167+
tail := rest[end+2:]
168+
169+
if len(expression) == 0 {
170+
return "", "", nil, errors.New("empty expression")
171+
}
172+
if len(expression) > regexMaxExpressionLen {
173+
return "", "", nil, fmt.Errorf("the expression is %d bytes long, the limit is %d", len(expression), regexMaxExpressionLen)
174+
}
175+
for i := 0; i < len(expression); i++ {
176+
c := expression[i]
177+
if c >= utf8.RuneSelf {
178+
return "", "", nil, fmt.Errorf("non-ASCII byte at offset %d of the expression; internationalized names must be written in their ASCII (xn--) form", i)
179+
}
180+
if c < 0x20 || c == 0x7f {
181+
return "", "", nil, fmt.Errorf("control character at offset %d of the expression", i)
182+
}
183+
}
184+
185+
if len(tail) > 0 && tail[0] != ' ' && tail[0] != '\t' {
186+
if strings.IndexByte(tail, '`') >= 0 {
187+
return "", "", nil, errors.New("backticks are not supported in expressions")
188+
}
189+
return "", "", nil, errors.New("expected a space or a tab after the closing backtick")
190+
}
191+
tail = strings.TrimLeft(tail, " \t")
192+
if strings.HasPrefix(tail, "@") {
193+
name := tail[1:]
194+
tail = ""
195+
if i := strings.IndexAny(name, " \t"); i >= 0 {
196+
name, tail = name[:i], strings.TrimLeft(name[i:], " \t")
197+
}
198+
switch {
199+
case len(name) == 0:
200+
return "", "", nil, errors.New("empty schedule name after @")
201+
case strings.ContainsFunc(name, unicode.IsSpace):
202+
return "", "", nil, errors.New("schedule names cannot contain whitespace")
203+
case strings.Contains(name, "@"):
204+
return "", "", nil, errors.New("only one schedule can be given")
205+
case strings.Contains(name, "#"):
206+
return "", "", nil, errors.New("expected a space or a tab between the schedule name and the comment")
207+
}
208+
ranges, ok := (*allWeeklyRanges)[name]
209+
if !ok {
210+
return "", "", nil, fmt.Errorf("time range [%s] not found", name)
211+
}
212+
weeklyRanges = &ranges
213+
}
214+
switch {
215+
case len(tail) == 0, tail[0] == '#':
216+
case tail[0] == '@':
217+
return "", "", nil, errors.New("only one schedule can be given")
218+
case strings.IndexByte(tail, '`') >= 0:
219+
return "", "", nil, errors.New("backticks are not supported in expressions")
220+
default:
221+
return "", "", nil, fmt.Errorf("unexpected text after the expression: %q", tail)
222+
}
223+
return directive, expression, weeklyRanges, nil
224+
}
225+
226+
// Eval returns the rule that applies to a normalized name right now.
227+
// Ordinary rules come first: if one matches but its schedule is inactive,
228+
// nothing applies.
229+
// Otherwise, the first active regex rule that matches wins.
230+
func (nameList *NameList) Eval(qName string) (matched bool, reason string, err error) {
231+
var now time.Time
232+
matched, reason, val := nameList.patternMatcher.Eval(qName)
233+
if matched {
234+
weeklyRanges, _ := val.(*WeeklyRanges)
235+
if !scheduleActive(weeklyRanges, &now) {
236+
return false, "", nil
237+
}
238+
return true, reason, nil
239+
}
240+
for i := range nameList.regexRules {
241+
rule := &nameList.regexRules[i]
242+
if !scheduleActive(rule.weeklyRanges, &now) {
243+
continue
244+
}
245+
if len(qName) > regexMaxSubjectLen {
246+
return false, "", fmt.Errorf("name is %d bytes long, longer than the %d bytes regex rules support", len(qName), regexMaxSubjectLen)
247+
}
248+
found, err := rule.re.MatchString(qName)
249+
if err != nil {
250+
return false, "", fmt.Errorf("%s:%d: %w", nameList.fileName, rule.line, err)
251+
}
252+
if found {
253+
return true, rule.directive, nil
254+
}
255+
}
256+
return false, "", nil
257+
}
258+
259+
// scheduleActive reads the clock at most once per name.
260+
func scheduleActive(weeklyRanges *WeeklyRanges, now *time.Time) bool {
261+
if weeklyRanges == nil {
262+
return true
263+
}
264+
if now.IsZero() {
265+
*now = time.Now().Local()
266+
}
267+
return weeklyRanges.Match(*now)
268+
}

0 commit comments

Comments
 (0)