|
| 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