-
-
Notifications
You must be signed in to change notification settings - Fork 611
/
Copy pathresponder.go
365 lines (333 loc) · 13.5 KB
/
responder.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
/*
This code was originally forked from https://github.com/cloudflare/cfssl/blob/1a911ca1b1d6e899bf97dcfa4a14b38db0d31134/ocsp/responder.go
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// Package responder implements an OCSP HTTP responder based on a generic
// storage backend.
package responder
import (
"context"
"crypto"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/url"
"time"
"github.com/jmhodges/clock"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/ocsp"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
)
// ErrNotFound indicates the request OCSP response was not found. It is used to
// indicate that the responder should reply with unauthorizedErrorResponse.
var ErrNotFound = errors.New("request OCSP Response not found")
// errOCSPResponseExpired indicates that the nextUpdate field of the requested
// OCSP response occurred in the past and an HTTP status code of 533 should be
// returned to the caller.
var errOCSPResponseExpired = errors.New("OCSP response is expired")
var responseTypeToString = map[ocsp.ResponseStatus]string{
ocsp.Success: "Success",
ocsp.Malformed: "Malformed",
ocsp.InternalError: "InternalError",
ocsp.TryLater: "TryLater",
ocsp.SignatureRequired: "SignatureRequired",
ocsp.Unauthorized: "Unauthorized",
}
// A Responder object provides an HTTP wrapper around a Source.
type Responder struct {
Source Source
timeout time.Duration
responseTypes *prometheus.CounterVec
responseAges prometheus.Histogram
requestSizes prometheus.Histogram
sampleRate int
clk clock.Clock
log blog.Logger
}
// NewResponder instantiates a Responder with the give Source.
func NewResponder(source Source, timeout time.Duration, stats prometheus.Registerer, logger blog.Logger, sampleRate int) *Responder {
requestSizes := prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "ocsp_request_sizes",
Help: "Size of OCSP requests",
Buckets: []float64{1, 100, 200, 400, 800, 1200, 2000, 5000, 10000},
},
)
stats.MustRegister(requestSizes)
// Set up 12-hour-wide buckets, measured in seconds.
buckets := make([]float64, 14)
for i := range buckets {
buckets[i] = 43200 * float64(i)
}
responseAges := prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "ocsp_response_ages",
Help: "How old are the OCSP responses when we serve them. Must stay well below 84 hours.",
Buckets: buckets,
})
stats.MustRegister(responseAges)
responseTypes := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "ocsp_responses",
Help: "Number of OCSP responses returned by type",
},
[]string{"type"},
)
stats.MustRegister(responseTypes)
return &Responder{
Source: source,
timeout: timeout,
responseTypes: responseTypes,
responseAges: responseAges,
requestSizes: requestSizes,
clk: clock.New(),
log: logger,
sampleRate: sampleRate,
}
}
type logEvent struct {
IP string `json:"ip,omitempty"`
UA string `json:"ua,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body string `json:"body,omitempty"`
Received time.Time `json:"received,omitempty"`
Took time.Duration `json:"took,omitempty"`
Headers http.Header `json:"headers,omitempty"`
Serial string `json:"serial,omitempty"`
IssuerKeyHash string `json:"issuerKeyHash,omitempty"`
IssuerNameHash string `json:"issuerNameHash,omitempty"`
HashAlg string `json:"hashAlg,omitempty"`
}
// hashToString contains mappings for the only hash functions
// x/crypto/ocsp supports
var hashToString = map[crypto.Hash]string{
crypto.SHA1: "SHA1",
crypto.SHA256: "SHA256",
crypto.SHA384: "SHA384",
crypto.SHA512: "SHA512",
}
func SampledError(log blog.Logger, sampleRate int, format string, a ...interface{}) {
if sampleRate > 0 && rand.IntN(sampleRate) == 0 {
log.Errf(format, a...)
}
}
func (rs Responder) sampledError(format string, a ...interface{}) {
SampledError(rs.log, rs.sampleRate, format, a...)
}
// ServeHTTP is a Responder that can process both GET and POST requests. The
// mapping from an OCSP request to an OCSP response is done by the Source; the
// Responder simply decodes the request, and passes back whatever response is
// provided by the source.
// The Responder will set these headers:
//
// Cache-Control: "max-age=(response.NextUpdate-now), public, no-transform, must-revalidate",
// Last-Modified: response.ThisUpdate,
// Expires: response.NextUpdate,
// ETag: the SHA256 hash of the response, and
// Content-Type: application/ocsp-response.
//
// Note: The caller must use http.StripPrefix to strip any path components
// (including '/') on GET requests.
// Do not use this responder in conjunction with http.NewServeMux, because the
// default handler will try to canonicalize path components by changing any
// strings of repeated '/' into a single '/', which will break the base64
// encoding.
func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Request) {
// We specifically ignore request.Context() because we would prefer for clients
// to not be able to cancel our operations in arbitrary places. Instead we
// start a new context, and apply timeouts in our various RPCs.
ctx := context.WithoutCancel(request.Context())
request = request.WithContext(ctx)
if rs.timeout != 0 {
var cancel func()
ctx, cancel = context.WithTimeout(ctx, rs.timeout)
defer cancel()
}
le := logEvent{
IP: request.RemoteAddr,
UA: request.UserAgent(),
Method: request.Method,
Path: request.URL.Path,
Received: time.Now(),
}
defer func() {
le.Headers = response.Header()
le.Took = time.Since(le.Received)
jb, err := json.Marshal(le)
if err != nil {
// we log this error at the debug level as if we aren't at that level anyway
// we shouldn't really care about marshalling the log event object
rs.log.Debugf("failed to marshal log event object: %s", err)
return
}
rs.log.Debugf("Received request: %s", string(jb))
}()
// By default we set a 'max-age=0, no-cache' Cache-Control header, this
// is only returned to the client if a valid authorized OCSP response
// is not found or an error is returned. If a response if found the header
// will be altered to contain the proper max-age and modifiers.
response.Header().Add("Cache-Control", "max-age=0, no-cache")
// Read response from request
var requestBody []byte
var err error
switch request.Method {
case "GET":
base64Request, err := url.QueryUnescape(request.URL.Path)
if err != nil {
rs.log.Debugf("Error decoding URL: %s", request.URL.Path)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
response.WriteHeader(http.StatusBadRequest)
return
}
// url.QueryUnescape not only unescapes %2B escaping, but it additionally
// turns the resulting '+' into a space, which makes base64 decoding fail.
// So we go back afterwards and turn ' ' back into '+'. This means we
// accept some malformed input that includes ' ' or %20, but that's fine.
base64RequestBytes := []byte(base64Request)
for i := range base64RequestBytes {
if base64RequestBytes[i] == ' ' {
base64RequestBytes[i] = '+'
}
}
// In certain situations a UA may construct a request that has a double
// slash between the host name and the base64 request body due to naively
// constructing the request URL. In that case strip the leading slash
// so that we can still decode the request.
if len(base64RequestBytes) > 0 && base64RequestBytes[0] == '/' {
base64RequestBytes = base64RequestBytes[1:]
}
requestBody, err = base64.StdEncoding.DecodeString(string(base64RequestBytes))
if err != nil {
rs.log.Debugf("Error decoding base64 from URL: %s", string(base64RequestBytes))
response.WriteHeader(http.StatusBadRequest)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
return
}
case "POST":
requestBody, err = io.ReadAll(http.MaxBytesReader(nil, request.Body, 10000))
if err != nil {
rs.log.Errf("Problem reading body of POST: %s", err)
response.WriteHeader(http.StatusBadRequest)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
return
}
rs.requestSizes.Observe(float64(len(requestBody)))
default:
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
b64Body := base64.StdEncoding.EncodeToString(requestBody)
rs.log.Debugf("Received OCSP request: %s", b64Body)
if request.Method == http.MethodPost {
le.Body = b64Body
}
// All responses after this point will be OCSP.
// We could check for the content type of the request, but that
// seems unnecessariliy restrictive.
response.Header().Add("Content-Type", "application/ocsp-response")
// Parse response as an OCSP request
// XXX: This fails if the request contains the nonce extension.
// We don't intend to support nonces anyway, but maybe we
// should return unauthorizedRequest instead of malformed.
ocspRequest, err := ocsp.ParseRequest(requestBody)
if err != nil {
rs.log.Debugf("Error decoding request body: %s", b64Body)
response.WriteHeader(http.StatusBadRequest)
response.Write(ocsp.MalformedRequestErrorResponse)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
return
}
le.Serial = fmt.Sprintf("%x", ocspRequest.SerialNumber.Bytes())
le.IssuerKeyHash = fmt.Sprintf("%x", ocspRequest.IssuerKeyHash)
le.IssuerNameHash = fmt.Sprintf("%x", ocspRequest.IssuerNameHash)
le.HashAlg = hashToString[ocspRequest.HashAlgorithm]
// Look up OCSP response from source
ocspResponse, err := rs.Source.Response(ctx, ocspRequest)
if err != nil {
if errors.Is(err, ErrNotFound) {
response.Write(ocsp.UnauthorizedErrorResponse)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc()
return
} else if errors.Is(err, errOCSPResponseExpired) {
rs.sampledError("Requested ocsp response is expired: serial %x, request body %s",
ocspRequest.SerialNumber, b64Body)
// HTTP StatusCode - unassigned
response.WriteHeader(533)
response.Write(ocsp.InternalErrorErrorResponse)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc()
return
}
rs.sampledError("Error retrieving response for request: serial %x, request body %s, error: %s",
ocspRequest.SerialNumber, b64Body, err)
response.WriteHeader(http.StatusInternalServerError)
response.Write(ocsp.InternalErrorErrorResponse)
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.InternalError]}).Inc()
return
}
// Write OCSP response
response.Header().Add("Last-Modified", ocspResponse.ThisUpdate.Format(time.RFC1123))
response.Header().Add("Expires", ocspResponse.NextUpdate.Format(time.RFC1123))
now := rs.clk.Now()
var maxAge int
if now.Before(ocspResponse.NextUpdate) {
maxAge = int(ocspResponse.NextUpdate.Sub(now) / time.Second)
} else {
// TODO(#530): we want max-age=0 but this is technically an authorized OCSP response
// (despite being stale) and 5019 forbids attaching no-cache
maxAge = 0
}
response.Header().Set(
"Cache-Control",
fmt.Sprintf(
"max-age=%d, public, no-transform, must-revalidate",
maxAge,
),
)
responseHash := sha256.Sum256(ocspResponse.Raw)
response.Header().Add("ETag", fmt.Sprintf("\"%X\"", responseHash))
serialString := core.SerialToString(ocspResponse.SerialNumber)
if len(serialString) > 2 {
// Set a cache tag that is equal to the last two bytes of the serial.
// We expect that to be randomly distributed, so each tag should map to
// about 1/256 of our responses.
response.Header().Add("Edge-Cache-Tag", serialString[len(serialString)-2:])
}
// RFC 7232 says that a 304 response must contain the above
// headers if they would also be sent for a 200 for the same
// request, so we have to wait until here to do this
if etag := request.Header.Get("If-None-Match"); etag != "" {
if etag == fmt.Sprintf("\"%X\"", responseHash) {
response.WriteHeader(http.StatusNotModified)
return
}
}
response.WriteHeader(http.StatusOK)
response.Write(ocspResponse.Raw)
rs.responseAges.Observe(rs.clk.Now().Sub(ocspResponse.ThisUpdate).Seconds())
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Success]}).Inc()
}