Observer: detect CRL IDP mismatch (#8067)

Give boulder-observer the ability to detect if the CRL it fetches is the
CRL it expects, by comparing that CRLs issuingDistributionPoint
extension to the prober's configured URL. Only do this if instructed to
(by configuring the CRL prober as "partitioned") because non-partitioned
CRLs do not necessarily contain an IDP.

Fixes https://github.com/letsencrypt/boulder/issues/7527
This commit is contained in:
Aaron Gable 2025-03-14 16:52:29 -05:00 committed by GitHub
parent ebf232cccb
commit d045b387ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 31 additions and 16 deletions

View File

@ -4,15 +4,19 @@ import (
"crypto/x509" "crypto/x509"
"io" "io"
"net/http" "net/http"
"slices"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/letsencrypt/boulder/crl/idp"
) )
// CRLProbe is the exported 'Prober' object for monitors configured to // CRLProbe is the exported 'Prober' object for monitors configured to
// monitor CRL availability & characteristics. // monitor CRL availability & characteristics.
type CRLProbe struct { type CRLProbe struct {
url string url string
partitioned bool
cNextUpdate *prometheus.GaugeVec cNextUpdate *prometheus.GaugeVec
cThisUpdate *prometheus.GaugeVec cThisUpdate *prometheus.GaugeVec
cCertCount *prometheus.GaugeVec cCertCount *prometheus.GaugeVec
@ -47,6 +51,19 @@ func (p CRLProbe) Probe(timeout time.Duration) (bool, time.Duration) {
return false, dur return false, dur
} }
// Partitioned CRLs MUST contain an issuingDistributionPoint extension, which
// MUST contain the URL from which they were fetched, to prevent substitution
// attacks.
if p.partitioned {
idps, err := idp.GetIDPURIs(crl.Extensions)
if err != nil {
return false, dur
}
if len(idps) != 0 && !slices.Contains(idps, p.url) {
return false, dur
}
}
// Report metrics for this CRL // Report metrics for this CRL
p.cThisUpdate.WithLabelValues(p.url).Set(float64(crl.ThisUpdate.Unix())) p.cThisUpdate.WithLabelValues(p.url).Set(float64(crl.ThisUpdate.Unix()))
p.cNextUpdate.WithLabelValues(p.url).Set(float64(crl.NextUpdate.Unix())) p.cNextUpdate.WithLabelValues(p.url).Set(float64(crl.NextUpdate.Unix()))

View File

@ -4,9 +4,10 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"github.com/prometheus/client_golang/prometheus"
"github.com/letsencrypt/boulder/observer/probers" "github.com/letsencrypt/boulder/observer/probers"
"github.com/letsencrypt/boulder/strictyaml" "github.com/letsencrypt/boulder/strictyaml"
"github.com/prometheus/client_golang/prometheus"
) )
const ( const (
@ -17,7 +18,8 @@ const (
// CRLConf is exported to receive YAML configuration // CRLConf is exported to receive YAML configuration
type CRLConf struct { type CRLConf struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Partitioned bool `yaml:"partitioned"`
} }
// Kind returns a name that uniquely identifies the `Kind` of `Configurer`. // Kind returns a name that uniquely identifies the `Kind` of `Configurer`.
@ -87,7 +89,7 @@ func (c CRLConf) MakeProber(collectors map[string]prometheus.Collector) (probers
return nil, fmt.Errorf("crl prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", certCountName, coll) return nil, fmt.Errorf("crl prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", certCountName, coll)
} }
return CRLProbe{c.URL, nextUpdateColl, thisUpdateColl, certCountColl}, nil return CRLProbe{c.URL, c.Partitioned, nextUpdateColl, thisUpdateColl, certCountColl}, nil
} }
// Instrument constructs any `prometheus.Collector` objects the `CRLProbe` will // Instrument constructs any `prometheus.Collector` objects the `CRLProbe` will

View File

@ -3,10 +3,11 @@ package probers
import ( import (
"testing" "testing"
"github.com/letsencrypt/boulder/observer/probers"
"github.com/letsencrypt/boulder/test"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/letsencrypt/boulder/observer/probers"
"github.com/letsencrypt/boulder/test"
) )
func TestCRLConf_MakeProber(t *testing.T) { func TestCRLConf_MakeProber(t *testing.T) {
@ -70,25 +71,20 @@ func TestCRLConf_MakeProber(t *testing.T) {
} }
func TestCRLConf_UnmarshalSettings(t *testing.T) { func TestCRLConf_UnmarshalSettings(t *testing.T) {
type fields struct {
url interface{}
}
tests := []struct { tests := []struct {
name string name string
fields fields fields probers.Settings
want probers.Configurer want probers.Configurer
wantErr bool wantErr bool
}{ }{
{"valid", fields{"google.com"}, CRLConf{"google.com"}, false}, {"valid", probers.Settings{"url": "google.com"}, CRLConf{"google.com", false}, false},
{"invalid (map)", fields{make(map[string]interface{})}, nil, true}, {"valid with partitioned", probers.Settings{"url": "google.com", "partitioned": true}, CRLConf{"google.com", true}, false},
{"invalid (list)", fields{make([]string, 0)}, nil, true}, {"invalid (map)", probers.Settings{"url": make(map[string]interface{})}, nil, true},
{"invalid (list)", probers.Settings{"url": make([]string, 0)}, nil, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
settings := probers.Settings{ settingsBytes, _ := yaml.Marshal(tt.fields)
"url": tt.fields.url,
}
settingsBytes, _ := yaml.Marshal(settings)
t.Log(string(settingsBytes)) t.Log(string(settingsBytes))
c := CRLConf{} c := CRLConf{}
got, err := c.UnmarshalSettings(settingsBytes) got, err := c.UnmarshalSettings(settingsBytes)