Skip to content

Commit 34d324b

Browse files
fix(metadata): consistent revocation policy and client timeouts (#740)
Revocation of an intermediate was gated on the certificate carrying an issuing certificate URL, while a leaf in the same position was rejected outright. Both now share one check: a certificate is rejected when it is known to be revoked. Fetch and the cached provider created clients with no timeout, so an unresponsive service stalled the caller indefinitely; both now use DefaultMDSTimeout. The decoder held a client that was never read, which is removed, and Decode no longer claims to close the reader it is given. The metadata service rate limits with a short plain text body, which was handed to the JWT parser and surfaced as an unintelligible base64 failure. The network tests now check the response status before decoding, and the conformance test no longer leaks a response body per endpoint.
1 parent 397152c commit 34d324b

6 files changed

Lines changed: 182 additions & 21 deletions

File tree

metadata/const.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package metadata
22

3+
import "time"
4+
35
const (
46
// ProductionMDSRoot is the root certificate for the MDS.
57
//
@@ -25,6 +27,10 @@ const (
2527
HeaderX509Certificate = "x5c"
2628
)
2729

30+
// DefaultMDSTimeout is the timeout applied to the [http.Client] values this package creates for itself. It is
31+
// deliberately generous as the production blob is several megabytes. Provide your own client to use a different value.
32+
const DefaultMDSTimeout = time.Second * 30
33+
2834
var (
2935
errIntermediateCertRevoked = &Error{
3036
Type: "intermediate_revoked",

metadata/decode.go

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"errors"
77
"fmt"
88
"io"
9-
"net/http"
109
"strings"
1110
"time"
1211

@@ -19,7 +18,6 @@ import (
1918
// NewDecoder returns a new metadata decoder.
2019
func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) {
2120
decoder = &Decoder{
22-
client: &http.Client{},
2321
parser: jwt.NewParser(),
2422
hook: mapstructure.ComposeDecodeHookFunc(),
2523
}
@@ -39,7 +37,6 @@ func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) {
3937

4038
// Decoder handles decoding and specialized parsing of the metadata blob.
4139
type Decoder struct {
42-
client *http.Client
4340
parser *jwt.Parser
4441
hook mapstructure.DecodeHookFunc
4542
root string
@@ -82,7 +79,8 @@ func (d *Decoder) Parse(payload *PayloadJSON) (metadata *Metadata, err error) {
8279
return metadata, nil
8380
}
8481

85-
// Decode the blob from an [io.Reader]. This function will close the [io.ReadCloser] after completing.
82+
// Decode the blob from an [io.Reader]. The reader is read in full but is not closed; closing it remains the
83+
// responsibility of the caller.
8684
func (d *Decoder) Decode(r io.Reader) (payload *PayloadJSON, err error) {
8785
bytes, err := io.ReadAll(r)
8886
if err != nil {
@@ -238,14 +236,8 @@ func validateChain(root string, chain []any) (bool, error) {
238236
return false, err
239237
}
240238

241-
if revoked, ok := revoke.VerifyCertificate(intcert); !ok {
242-
issuer := intcert.IssuingCertificateURL
243-
244-
if issuer != nil {
245-
return false, errCRLUnavailable
246-
}
247-
} else if revoked {
248-
return false, errIntermediateCertRevoked
239+
if err = validateChainCheckRevocation(intcert, errIntermediateCertRevoked); err != nil {
240+
return false, err
249241
}
250242

251243
ints.AddCert(intcert)
@@ -256,10 +248,8 @@ func validateChain(root string, chain []any) (bool, error) {
256248
return false, err
257249
}
258250

259-
if revoked, ok := revoke.VerifyCertificate(leafcert); !ok {
260-
return false, errCRLUnavailable
261-
} else if revoked {
262-
return false, errLeafCertRevoked
251+
if err = validateChainCheckRevocation(leafcert, errLeafCertRevoked); err != nil {
252+
return false, err
263253
}
264254

265255
opts := x509.VerifyOptions{
@@ -273,6 +263,14 @@ func validateChain(root string, chain []any) (bool, error) {
273263
return err == nil, err
274264
}
275265

266+
func validateChainCheckRevocation(cert *x509.Certificate, revokedErr error) error {
267+
if revoked, ok := revoke.VerifyCertificate(cert); ok && revoked {
268+
return revokedErr
269+
}
270+
271+
return nil
272+
}
273+
276274
func mdsParseX509Certificate(value string) (certificate *x509.Certificate, err error) {
277275
var n int
278276

metadata/decode_test.go

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import (
77
"crypto/x509"
88
"crypto/x509/pkix"
99
"encoding/base64"
10+
"io"
1011
"math/big"
12+
"net/http"
13+
"net/http/httptest"
1114
"strings"
1215
"testing"
1316
"time"
@@ -126,7 +129,7 @@ func TestValidateChainDepth(t *testing.T) {
126129
}
127130
}
128131

129-
func newTestCertificate(t *testing.T, serial int64, name string, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, ca bool) (cert *x509.Certificate, key *ecdsa.PrivateKey, encoded string) {
132+
func newTestCertificate(t *testing.T, serial int64, name string, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, ca bool, mutators ...func(template *x509.Certificate)) (cert *x509.Certificate, key *ecdsa.PrivateKey, encoded string) {
130133
t.Helper()
131134

132135
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -148,6 +151,10 @@ func newTestCertificate(t *testing.T, serial int64, name string, parent *x509.Ce
148151
template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageAny}
149152
}
150153

154+
for _, mutator := range mutators {
155+
mutator(template)
156+
}
157+
151158
signer, signerKey := template, key
152159

153160
if parent != nil {
@@ -162,3 +169,132 @@ func newTestCertificate(t *testing.T, serial int64, name string, parent *x509.Ce
162169

163170
return cert, key, base64.StdEncoding.EncodeToString(der)
164171
}
172+
173+
// unreachableCRL points the certificate at a distribution point which cannot be reached, so that its revocation status
174+
// cannot be determined.
175+
func unreachableCRL(template *x509.Certificate) {
176+
template.CRLDistributionPoints = []string{"http://127.0.0.1:1/crl"}
177+
}
178+
179+
// newRevocationListServer serves a CRL signed by the given issuer which lists the given serials as revoked.
180+
func newRevocationListServer(t *testing.T, issuer *x509.Certificate, issuerKey *ecdsa.PrivateKey, revoked ...int64) *httptest.Server {
181+
t.Helper()
182+
183+
entries := make([]x509.RevocationListEntry, len(revoked))
184+
185+
for i, serial := range revoked {
186+
entries[i] = x509.RevocationListEntry{
187+
SerialNumber: big.NewInt(serial),
188+
RevocationTime: time.Now().Add(-time.Hour),
189+
}
190+
}
191+
192+
der, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{
193+
Number: big.NewInt(1),
194+
ThisUpdate: time.Now().Add(-time.Hour),
195+
NextUpdate: time.Now().Add(time.Hour),
196+
RevokedCertificateEntries: entries,
197+
}, issuer, issuerKey)
198+
require.NoError(t, err)
199+
200+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201+
w.Header().Set("Content-Type", "application/pkix-crl")
202+
203+
_, _ = w.Write(der)
204+
}))
205+
206+
t.Cleanup(server.Close)
207+
208+
return server
209+
}
210+
211+
func TestValidateChainRevocation(t *testing.T) {
212+
root, rootKey, rootEncoded := newTestCertificate(t, 20, "root", nil, nil, true)
213+
inter, interKey, interEncoded := newTestCertificate(t, 21, "intermediate", root, rootKey, true)
214+
215+
// A certificate whose status cannot be determined is treated as not revoked, as the distribution point is not
216+
// reliably reachable at the moment a blob is decoded.
217+
_, _, leafUnknown := newTestCertificate(t, 22, "leaf unknown", inter, interKey, false, unreachableCRL)
218+
219+
leafCRL := newRevocationListServer(t, inter, interKey, 23)
220+
221+
_, _, leafRevoked := newTestCertificate(t, 23, "leaf revoked", inter, interKey, false, func(template *x509.Certificate) {
222+
template.CRLDistributionPoints = []string{leafCRL.URL}
223+
})
224+
225+
interCRL := newRevocationListServer(t, root, rootKey, 25)
226+
227+
interRevoked, interRevokedKey, interRevokedEncoded := newTestCertificate(t, 25, "intermediate revoked", root, rootKey, true, func(template *x509.Certificate) {
228+
template.CRLDistributionPoints = []string{interCRL.URL}
229+
})
230+
231+
_, _, leafOfRevoked := newTestCertificate(t, 26, "leaf of revoked", interRevoked, interRevokedKey, false)
232+
_, _, leafOK := newTestCertificate(t, 24, "leaf", inter, interKey, false)
233+
234+
testCases := []struct {
235+
name string
236+
chain []any
237+
valid bool
238+
err error
239+
}{
240+
{
241+
name: "ShouldPermitLeafWithUnknownStatus",
242+
chain: []any{leafUnknown, interEncoded},
243+
valid: true,
244+
},
245+
{
246+
name: "ShouldPermitDeterminableChain",
247+
chain: []any{leafOK, interEncoded},
248+
valid: true,
249+
},
250+
{
251+
name: "ShouldRejectRevokedLeaf",
252+
chain: []any{leafRevoked, interEncoded},
253+
valid: false,
254+
err: errLeafCertRevoked,
255+
},
256+
{
257+
name: "ShouldRejectRevokedIntermediate",
258+
chain: []any{leafOfRevoked, interRevokedEncoded},
259+
valid: false,
260+
err: errIntermediateCertRevoked,
261+
},
262+
}
263+
264+
for _, tc := range testCases {
265+
t.Run(tc.name, func(t *testing.T) {
266+
valid, err := validateChain(rootEncoded, tc.chain)
267+
268+
assert.Equal(t, tc.valid, valid)
269+
270+
if tc.err != nil {
271+
assert.Equal(t, tc.err, err)
272+
} else {
273+
assert.NoError(t, err)
274+
}
275+
})
276+
}
277+
}
278+
279+
type recordingReadCloser struct {
280+
io.Reader
281+
closed bool
282+
}
283+
284+
func (r *recordingReadCloser) Close() error {
285+
r.closed = true
286+
287+
return nil
288+
}
289+
290+
func TestDecodeDoesNotCloseTheReader(t *testing.T) {
291+
decoder, err := NewDecoder()
292+
require.NoError(t, err)
293+
294+
rc := &recordingReadCloser{Reader: strings.NewReader("not a jwt")}
295+
296+
_, err = decoder.Decode(rc)
297+
require.Error(t, err)
298+
299+
assert.False(t, rc.closed, "Decode must leave closing the reader to the caller")
300+
}

metadata/metadata.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func Fetch() (metadata *Metadata, err error) {
2121
resp *http.Response
2222
)
2323

24-
client := &http.Client{}
24+
client := &http.Client{Timeout: DefaultMDSTimeout}
2525

2626
if resp, err = client.Get(ProductionMDSURL); err != nil {
2727
return nil, err

metadata/metadata_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"io"
89
"net/http"
910
"testing"
@@ -20,11 +21,17 @@ func TestProductionMetadataTOCParsing(t *testing.T) {
2021
decoder, err := NewDecoder(WithIgnoreEntryParsingErrors())
2122
require.NoError(t, err)
2223

23-
client := &http.Client{}
24+
client := &http.Client{Timeout: DefaultMDSTimeout}
2425

2526
res, err := client.Get(ProductionMDSURL)
2627
require.NoError(t, err)
2728

29+
defer func() {
30+
_ = res.Body.Close()
31+
}()
32+
33+
require.Equalf(t, http.StatusOK, res.StatusCode, "unexpected status code %d fetching the metadata blob", res.StatusCode)
34+
2835
payload, err := decoder.Decode(res.Body)
2936
require.NoError(t, err)
3037

@@ -89,7 +96,13 @@ func TestConformanceMetadataTOCParsing(t *testing.T) {
8996
res, err = client.Get(endpoint)
9097
require.NoError(t, err)
9198

92-
if blob, err = decoder.Decode(res.Body); err != nil {
99+
require.Equalf(t, http.StatusOK, res.StatusCode, "unexpected status code %d fetching conformance blob '%s'", res.StatusCode, endpoint)
100+
101+
blob, err = decoder.Decode(res.Body)
102+
103+
_ = res.Body.Close()
104+
105+
if err != nil {
93106
if errors.As(err, &me) {
94107
t.Log(me.Details)
95108
}
@@ -341,6 +354,10 @@ func getEndpoints(c *http.Client) ([]string, error) {
341354

342355
defer req.Body.Close()
343356

357+
if req.StatusCode != http.StatusOK {
358+
return nil, fmt.Errorf("error occurred requesting the conformance endpoints: unexpected status code %d", req.StatusCode)
359+
}
360+
344361
body, _ := io.ReadAll(req.Body)
345362

346363
var resp MDSGetEndpointsResponse
@@ -374,6 +391,10 @@ func getTestMetadata(s string, c *http.Client) (StatementJSON, error) {
374391

375392
defer req.Body.Close()
376393

394+
if req.StatusCode != http.StatusOK {
395+
return statement, fmt.Errorf("error occurred requesting the conformance test metadata for '%s': unexpected status code %d", s, req.StatusCode)
396+
}
397+
377398
body, err := io.ReadAll(req.Body)
378399
if err != nil {
379400
return statement, err

metadata/providers/cached/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func (p *Provider) outdated(mds *metadata.Metadata) bool {
156156

157157
func (p *Provider) get() (data []byte, err error) {
158158
if p.client == nil {
159-
p.client = &http.Client{}
159+
p.client = &http.Client{Timeout: metadata.DefaultMDSTimeout}
160160
}
161161

162162
var res *http.Response

0 commit comments

Comments
 (0)