-
-
Notifications
You must be signed in to change notification settings - Fork 644
observer: ccadb: check reported CRL shards for completeness #8890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,15 +10,18 @@ import ( | |
| "encoding/pem" | ||
| "errors" | ||
| "fmt" | ||
| "github.com/letsencrypt/boulder/observer/probers" | ||
| "github.com/letsencrypt/boulder/strictyaml" | ||
| "github.com/prometheus/client_golang/prometheus" | ||
| "io" | ||
| "maps" | ||
| "net/http" | ||
| "regexp" | ||
| "slices" | ||
| "strconv" | ||
| "time" | ||
|
|
||
| "github.com/letsencrypt/boulder/observer/probers" | ||
| "github.com/letsencrypt/boulder/strictyaml" | ||
| "github.com/prometheus/client_golang/prometheus" | ||
|
|
||
| "github.com/letsencrypt/boulder/crl/checker" | ||
| "github.com/letsencrypt/boulder/crl/idp" | ||
| ) | ||
|
|
@@ -29,9 +32,7 @@ type CCADBConf struct { | |
| CertificatePEMsURL string `yaml:"certificatePEMsURL"` | ||
| CAOwner string `yaml:"caOwner"` | ||
| CRLAgeLimit string `yaml:"crlAgeLimit"` | ||
| // Because this prober fetches URLs controlled by external input (CCADB), we | ||
| // check this regexp to avoid arbitrary content fetching (SSRF). | ||
|
Comment on lines
-32
to
-33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally, I'd keep this comment rather than deleting it entirely. Or I'd move it down to line 83 and say something like "While the primary purpose of the regexp is to parse out the CRL Number, we specify our own c.lencr.org hostname to ensure that CCADB doesn't cause us to fetch arbitrary external data (SSRF)." |
||
| CRLRegexp string `yaml:"crlRegexp"` | ||
| CRLRegexp string `yaml:"crlRegexp"` | ||
| } | ||
|
|
||
| // Kind returns a name that uniquely identifies the `Kind` of `Configurer`. | ||
|
|
@@ -79,7 +80,7 @@ func (c CCADBConf) MakeProber(collectors map[string]prometheus.Collector) (probe | |
| } | ||
| } | ||
|
|
||
| crlRegexp := `^http://[a-z0-9-]+\.c\.lencr\.org/\d+\.crl$` | ||
| crlRegexp := `^http://[a-z0-9-]+\.c\.lencr\.org/(?P<crlNumber>\d+)\.crl$` | ||
| if c.CRLRegexp != "" { | ||
| crlRegexp = c.CRLRegexp | ||
| } | ||
|
|
@@ -117,16 +118,21 @@ func getIDP(crl *x509.RevocationList) (string, error) { | |
| return "", fmt.Errorf("CRL had incorrect number of IssuingDistributionPoint URIs: %s", idps) | ||
| } | ||
|
|
||
| // CCADBProber fetches the AllCertificatesRecordsReport from CCADB, filters for a | ||
| // specific CA Owner (defaults to 'Internet Security Research Group'), and | ||
| // fetches all CRLs found. | ||
| // CCADBProber checks the CRLs we report in CCADB for correctness. | ||
| // | ||
| // It determines the CRLs in scope by fetching the AllCertificatesRecordsReport | ||
| // from CCADB, and filtering for a specific CA Owner (defaults to 'Internet | ||
| // Security Research Group'). | ||
| // | ||
| // It checks that the CRLs: | ||
| // - Are not too old | ||
| // - Have an issuingDistributionPoint that matches the URL from which they | ||
| // were fetched | ||
| // - Have a valid signature based on their issuer SKID from CCADB | ||
| // - Don't have duplicate serial numbers across different CRLs | ||
| // | ||
| // It also checks, heuristically, whether the complete corpus of CRL shards | ||
| // are reported in CCADB. | ||
| type CCADBProber struct { | ||
| allCertificatesCSVURL string | ||
| certificatePEMsURL string | ||
|
|
@@ -164,16 +170,33 @@ func (c *CCADBProber) Probe(ctx context.Context) error { | |
| continue | ||
| } | ||
|
|
||
| var ( | ||
| seenCRLShardIndices []int | ||
| exampleCRLShardURL string | ||
| ) | ||
| for _, url := range urls { | ||
| // This can happen when an issuer is not yet issuing. | ||
| if url == "" { | ||
| continue | ||
| } | ||
|
|
||
| if !c.crlRegexp.MatchString(url) { | ||
| matches := c.crlRegexp.FindAllStringSubmatch(url, 2) | ||
| if matches == nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we (or anyone else running this code) currently configure a non-default CRLRegexp, then this code is not backwards-compatible. Whatever regexp is configured almost certainly doesn't have any capture groups in it, which means that the prober will bail out at this error check.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indeed it is not backwards compatible. This is not a problem in LE's configuration because we use the default. |
||
| errs = append(errs, fmt.Errorf("CRL %s does not match regexp %s", url, c.crlRegexp)) | ||
| continue | ||
| } | ||
| match := matches[0] | ||
| if len(match) != 2 { | ||
| errs = append(errs, fmt.Errorf("CRL %s does not match regexp %s", url, c.crlRegexp)) | ||
| continue | ||
| } | ||
| crlNumber, err := strconv.Atoi(match[1]) | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("cannot parse CRL shard number from %s: %s", url, err)) | ||
| continue | ||
| } | ||
| seenCRLShardIndices = append(seenCRLShardIndices, crlNumber) | ||
| exampleCRLShardURL = url | ||
|
|
||
| crl, err := checkCRL(ctx, url, issuer, c.crlAgeLimit) | ||
| if err != nil { | ||
|
|
@@ -199,6 +222,31 @@ func (c *CCADBProber) Probe(ctx context.Context) error { | |
| serials[serialByteString] = crl | ||
| } | ||
| } | ||
|
|
||
| if len(seenCRLShardIndices) > 0 { | ||
| // The max()+1th shard should not be live. This generally occurs if we have | ||
| // live CRL shards that are not reported in CCADB. | ||
| maxShardIndex := slices.Max(seenCRLShardIndices) | ||
| err := checkCRLShardNotFound(ctx, c.crlRegexp, exampleCRLShardURL, maxShardIndex+1) | ||
| if err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
|
|
||
| // The 0th shard should not be live, because shards are 1-indexed. | ||
| err = checkCRLShardNotFound(ctx, c.crlRegexp, exampleCRLShardURL, 0) | ||
| if err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
|
|
||
| // It is possible that the number of CRL shards shrinks, but it is highly | ||
| // unlikely, because we would never have any reason make that so. Therefore | ||
| // we do not detect this case. | ||
|
|
||
| err = checkAllShardIndexesPresent(seenCRLShardIndices) | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("issuer %q: %w", issuer.Subject.CommonName, err)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return errors.Join(errs...) | ||
|
|
@@ -243,6 +291,50 @@ func getCSV(ctx context.Context, url string) ([]string, *csv.Reader, error) { | |
| return header, reader, nil | ||
| } | ||
|
|
||
| func checkCRLShardNotFound(ctx context.Context, re *regexp.Regexp, exampleCRLShardURL string, shardIndex int) error { | ||
| match := re.FindStringSubmatchIndex(exampleCRLShardURL) | ||
| if match == nil || len(match) != 4 || match[2] < 0 { | ||
| return fmt.Errorf("CRL %s does not match regexp %s", exampleCRLShardURL, re) | ||
| } | ||
| // There is mild SSRF risk because this input is derived from CCADB controlled | ||
| // values. This is mitigated by using a stringent regex. | ||
| url := exampleCRLShardURL[:match[2]] + strconv.Itoa(shardIndex) + exampleCRLShardURL[match[3]:] | ||
|
|
||
| err := httpGetExpectingStatusCode(ctx, url, http.StatusNotFound) | ||
| if err != nil { | ||
| return fmt.Errorf("did not get expected status 404 for %s: %w", url, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // checkAllShardIndexesPresent returns an error naming any index in | ||
| // [1..max(seen)] that is absent from seen. | ||
| func checkAllShardIndexesPresent(seen []int) error { | ||
| if len(seen) == 0 { | ||
| return nil | ||
| } | ||
| seenSet := make(map[int]struct{}, len(seen)) | ||
| for _, index := range seen { | ||
| seenSet[index] = struct{}{} | ||
| } | ||
| var missing []int | ||
| maxIndex := slices.Max(seen) | ||
| if maxIndex > 100_000 { | ||
| // Avoid unbounded allocation via typos in CCADB, e.g. 99999999.crl | ||
| return fmt.Errorf("CRL corpus is unexpectedly large: %d", maxIndex) | ||
| } | ||
| for i := 1; i <= maxIndex; i++ { | ||
| _, ok := seenSet[i] | ||
| if !ok { | ||
| missing = append(missing, i) | ||
| } | ||
| } | ||
| if len(missing) > 0 { | ||
| return fmt.Errorf("CRL shard indexes %v not reported in CCADB", missing) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (c CCADBProber) getAllIntermediates(ctx context.Context) (map[string]*x509.Certificate, error) { | ||
| certs, err := c.getDecadeIntermediates(ctx, 2010) | ||
| if err != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Order third-party packages in a separate cluster, above other boulder packages.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do, but is this automatable? Is there a configuration of goimports or golangci-lint I may be missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In vscode, it's
I'm not sure what that translates to in other individual tools.