1 package va
2
3 import (
4 "context"
5 "fmt"
6 "net/url"
7 "regexp"
8 "strings"
9 "sync"
10
11 "github.com/letsencrypt/boulder/core"
12 corepb "github.com/letsencrypt/boulder/core/proto"
13 berrors "github.com/letsencrypt/boulder/errors"
14 "github.com/letsencrypt/boulder/identifier"
15 "github.com/letsencrypt/boulder/probs"
16 vapb "github.com/letsencrypt/boulder/va/proto"
17 "github.com/miekg/dns"
18 )
19
20 type caaParams struct {
21 accountURIID int64
22 validationMethod core.AcmeChallenge
23 }
24
25 func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsCAAValidRequest) (*vapb.IsCAAValidResponse, error) {
26 if req.Domain == "" || req.ValidationMethod == "" || req.AccountURIID == 0 {
27 return nil, berrors.InternalServerError("incomplete IsCAAValid request")
28 }
29
30 validationMethod := core.AcmeChallenge(req.ValidationMethod)
31 if !validationMethod.IsValid() {
32 return nil, berrors.InternalServerError("unrecognized validation method %q", req.ValidationMethod)
33 }
34
35 acmeID := identifier.ACMEIdentifier{
36 Type: identifier.DNS,
37 Value: req.Domain,
38 }
39 params := &caaParams{
40 accountURIID: req.AccountURIID,
41 validationMethod: validationMethod,
42 }
43 if prob := va.checkCAA(ctx, acmeID, params); prob != nil {
44 detail := fmt.Sprintf("While processing CAA for %s: %s", req.Domain, prob.Detail)
45 return &vapb.IsCAAValidResponse{
46 Problem: &corepb.ProblemDetails{
47 ProblemType: string(prob.Type),
48 Detail: replaceInvalidUTF8([]byte(detail)),
49 },
50 }, nil
51 }
52 return &vapb.IsCAAValidResponse{}, nil
53 }
54
55
56
57 func (va *ValidationAuthorityImpl) checkCAA(
58 ctx context.Context,
59 identifier identifier.ACMEIdentifier,
60 params *caaParams) *probs.ProblemDetails {
61 if params == nil || params.validationMethod == "" || params.accountURIID == 0 {
62 return probs.ServerInternal("expected validationMethod or accountURIID not provided to checkCAA")
63 }
64
65 foundAt, valid, response, err := va.checkCAARecords(ctx, identifier, params)
66 if err != nil {
67 return probs.DNS(err.Error())
68 }
69
70 va.log.AuditInfof("Checked CAA records for %s, [Present: %t, Account ID: %d, Challenge: %s, Valid for issuance: %t, Found at: %q] Response=%q",
71 identifier.Value, foundAt != "", params.accountURIID, params.validationMethod, valid, foundAt, response)
72 if !valid {
73 return probs.CAA(fmt.Sprintf("CAA record for %s prevents issuance", foundAt))
74 }
75 return nil
76 }
77
78
79
80
81
82 type caaResult struct {
83 name string
84 present bool
85 issue []*dns.CAA
86 issuewild []*dns.CAA
87 criticalUnknown bool
88 dig string
89 err error
90 }
91
92
93
94
95
96 func filterCAA(rrs []*dns.CAA) ([]*dns.CAA, []*dns.CAA, bool) {
97 var issue, issuewild []*dns.CAA
98 var criticalUnknown bool
99
100 for _, caaRecord := range rrs {
101 switch strings.ToLower(caaRecord.Tag) {
102 case "issue":
103 issue = append(issue, caaRecord)
104 case "issuewild":
105 issuewild = append(issuewild, caaRecord)
106 case "iodef":
107
108
109
110
111 continue
112 default:
113
114
115
116
117
118
119 if (caaRecord.Flag & (128 | 1)) != 0 {
120 criticalUnknown = true
121 }
122 }
123 }
124
125 return issue, issuewild, criticalUnknown
126 }
127
128
129
130
131
132 func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string) []caaResult {
133 labels := strings.Split(name, ".")
134 results := make([]caaResult, len(labels))
135 var wg sync.WaitGroup
136
137 for i := 0; i < len(labels); i++ {
138
139 wg.Add(1)
140 go func(name string, r *caaResult) {
141 r.name = name
142 var records []*dns.CAA
143 records, r.dig, r.err = va.dnsClient.LookupCAA(ctx, name)
144 if len(records) > 0 {
145 r.present = true
146 }
147 r.issue, r.issuewild, r.criticalUnknown = filterCAA(records)
148 wg.Done()
149 }(strings.Join(labels[i:], "."), &results[i])
150 }
151
152 wg.Wait()
153 return results
154 }
155
156
157
158
159
160
161 func selectCAA(rrs []caaResult) (*caaResult, error) {
162 for _, res := range rrs {
163 if res.err != nil {
164 return nil, res.err
165 }
166 if res.present {
167 return &res, nil
168 }
169 }
170 return nil, nil
171 }
172
173
174
175
176
177
178
179
180 func (va *ValidationAuthorityImpl) getCAA(ctx context.Context, hostname string) (*caaResult, error) {
181 hostname = strings.TrimRight(hostname, ".")
182
183
184
185
186
187
188
189
190
191
192 results := va.parallelCAALookup(ctx, hostname)
193 return selectCAA(results)
194 }
195
196
197
198
199
200
201
202
203
204
205
206 func (va *ValidationAuthorityImpl) checkCAARecords(
207 ctx context.Context,
208 identifier identifier.ACMEIdentifier,
209 params *caaParams) (string, bool, string, error) {
210 hostname := strings.ToLower(identifier.Value)
211
212 var wildcard bool
213 if strings.HasPrefix(hostname, `*.`) {
214 hostname = strings.TrimPrefix(identifier.Value, `*.`)
215 wildcard = true
216 }
217 caaSet, err := va.getCAA(ctx, hostname)
218 if err != nil {
219 return "", false, "", err
220 }
221 raw := ""
222 if caaSet != nil {
223 raw = caaSet.dig
224 }
225 valid, foundAt := va.validateCAA(caaSet, wildcard, params)
226 return foundAt, valid, raw, nil
227 }
228
229
230
231
232
233
234
235 func (va *ValidationAuthorityImpl) validateCAA(caaSet *caaResult, wildcard bool, params *caaParams) (bool, string) {
236 if caaSet == nil {
237
238 va.metrics.caaCounter.WithLabelValues("no records").Inc()
239 return true, ""
240 }
241
242 if caaSet.criticalUnknown {
243
244 va.metrics.caaCounter.WithLabelValues("record with unknown critical directive").Inc()
245 return false, caaSet.name
246 }
247
248 if len(caaSet.issue) == 0 && !wildcard {
249
250
251
252
253 va.metrics.caaCounter.WithLabelValues("no relevant records").Inc()
254 return true, caaSet.name
255 }
256
257
258
259
260
261
262
263
264
265
266 records := caaSet.issue
267 if wildcard && len(caaSet.issuewild) > 0 {
268 records = caaSet.issuewild
269 }
270
271
272
273
274
275
276 for _, caa := range records {
277 parsedDomain, parsedParams, err := parseCAARecord(caa)
278 if err != nil {
279 continue
280 }
281
282 if !caaDomainMatches(parsedDomain, va.issuerDomain) {
283 continue
284 }
285
286 if !caaAccountURIMatches(parsedParams, va.accountURIPrefixes, params.accountURIID) {
287 continue
288 }
289
290 if !caaValidationMethodMatches(parsedParams, params.validationMethod) {
291 continue
292 }
293
294 va.metrics.caaCounter.WithLabelValues("authorized").Inc()
295 return true, caaSet.name
296 }
297
298
299 va.metrics.caaCounter.WithLabelValues("unauthorized").Inc()
300 return false, caaSet.name
301 }
302
303
304
305
306
307
308
309 func parseCAARecord(caa *dns.CAA) (string, map[string]string, error) {
310 isWSP := func(r rune) bool {
311 return r == '\t' || r == ' '
312 }
313
314
315
316 parts := strings.Split(caa.Value, ";")
317 domain := strings.TrimFunc(parts[0], isWSP)
318 paramList := parts[1:]
319 parameters := make(map[string]string)
320
321
322
323 if len(paramList) == 1 && strings.TrimFunc(paramList[0], isWSP) == "" {
324 return domain, parameters, nil
325 }
326
327 for _, parameter := range paramList {
328
329
330 tv := strings.SplitN(parameter, "=", 2)
331 if len(tv) != 2 {
332 return "", nil, fmt.Errorf("parameter not formatted as tag=value: %q", parameter)
333 }
334
335 tag := strings.TrimFunc(tv[0], isWSP)
336
337 for _, r := range []rune(tag) {
338
339
340 if r < 0x30 || (r > 0x39 && r < 0x41) || (r > 0x5a && r < 0x61) || r > 0x7a {
341 return "", nil, fmt.Errorf("tag contains disallowed character: %q", tag)
342 }
343 }
344
345 value := strings.TrimFunc(tv[1], isWSP)
346
347 for _, r := range []rune(value) {
348
349
350 if r < 0x21 || (r > 0x3a && r < 0x3c) || r > 0x7e {
351 return "", nil, fmt.Errorf("value contains disallowed character: %q", value)
352 }
353 }
354
355 parameters[tag] = value
356 }
357
358 return domain, parameters, nil
359 }
360
361
362
363 func caaDomainMatches(caaDomain string, issuerDomain string) bool {
364 return caaDomain == issuerDomain
365 }
366
367
368
369
370
371 func caaAccountURIMatches(caaParams map[string]string, accountURIPrefixes []string, accountID int64) bool {
372 accountURI, ok := caaParams["accounturi"]
373 if !ok {
374 return true
375 }
376
377
378 _, err := url.Parse(accountURI)
379 if err != nil {
380 return false
381 }
382
383 for _, prefix := range accountURIPrefixes {
384 if accountURI == fmt.Sprintf("%s%d", prefix, accountID) {
385 return true
386 }
387 }
388 return false
389 }
390
391 var validationMethodRegexp = regexp.MustCompile(`^[[:alnum:]-]+$`)
392
393
394
395
396
397 func caaValidationMethodMatches(caaParams map[string]string, method core.AcmeChallenge) bool {
398 commaSeparatedMethods, ok := caaParams["validationmethods"]
399 if !ok {
400 return true
401 }
402
403 for _, m := range strings.Split(commaSeparatedMethods, ",") {
404
405
406 if !validationMethodRegexp.MatchString(m) {
407 return false
408 }
409
410 caaMethod := core.AcmeChallenge(m)
411 if !caaMethod.IsValid() {
412 continue
413 }
414
415 if caaMethod == method {
416 return true
417 }
418 }
419 return false
420 }
421
View as plain text