github.com/letsencrypt/boulder@v0.20251208.0/sfe/sfe.go (about) 1 package sfe 2 3 import ( 4 "embed" 5 "errors" 6 "fmt" 7 "html/template" 8 "io/fs" 9 "net/http" 10 "net/url" 11 "strconv" 12 "strings" 13 "time" 14 15 "github.com/go-jose/go-jose/v4/jwt" 16 "github.com/jmhodges/clock" 17 "github.com/prometheus/client_golang/prometheus" 18 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 19 20 "github.com/letsencrypt/boulder/core" 21 emailpb "github.com/letsencrypt/boulder/email/proto" 22 blog "github.com/letsencrypt/boulder/log" 23 "github.com/letsencrypt/boulder/metrics/measured_http" 24 rapb "github.com/letsencrypt/boulder/ra/proto" 25 rl "github.com/letsencrypt/boulder/ratelimits" 26 sapb "github.com/letsencrypt/boulder/sa/proto" 27 "github.com/letsencrypt/boulder/sfe/zendesk" 28 "github.com/letsencrypt/boulder/unpause" 29 ) 30 31 const ( 32 unpausePostForm = unpause.APIPrefix + "/do-unpause" 33 unpauseStatus = unpause.APIPrefix + "/unpause-status" 34 35 overridesNewOrdersPerAccount = overridesAPIPrefix + "/overrides/new-orders-per-account" 36 overridesCertificatesPerDomain = overridesAPIPrefix + "/overrides/certificates-per-domain" 37 overridesCertificatesPerIP = overridesAPIPrefix + "/overrides/certificates-per-ip" 38 overridesCertificatesPerDomainPerAccount = overridesAPIPrefix + "/overrides/certificates-per-domain-per-account" 39 overridesValidateField = overridesAPIPrefix + "/overrides/validate-field" 40 overridesSubmitRequest = overridesAPIPrefix + "/overrides/submit-override-request" 41 overridesAutoApprovedSuccess = overridesAPIPrefix + "/overrides/auto-approved-success" 42 overridesRequestSubmittedSuccess = overridesAPIPrefix + "/overrides/request-submitted-success" 43 ) 44 45 var ( 46 //go:embed all:static 47 staticFS embed.FS 48 49 //go:embed all:templates all:pages all:static 50 dynamicFS embed.FS 51 ) 52 53 // SelfServiceFrontEndImpl provides all the logic for Boulder's selfservice 54 // frontend web-facing interface, i.e., a portal where a subscriber can unpause 55 // their account. Its methods are primarily handlers for HTTPS requests for the 56 // various non-ACME functions. 57 type SelfServiceFrontEndImpl struct { 58 ra rapb.RegistrationAuthorityClient 59 sa sapb.StorageAuthorityReadOnlyClient 60 ee emailpb.ExporterClient 61 62 log blog.Logger 63 clk clock.Clock 64 65 // requestTimeout is the per-request overall timeout. 66 requestTimeout time.Duration 67 68 unpauseHMACKey []byte 69 zendeskClient *zendesk.Client 70 71 templatePages *template.Template 72 cop *http.CrossOriginProtection 73 74 limiter *rl.Limiter 75 txnBuilder *rl.TransactionBuilder 76 77 // autoApproveOverrides only affects specific tiers and limits, see 78 // cmd/sfe/main.go for details. 79 autoApproveOverrides bool 80 } 81 82 // NewSelfServiceFrontEndImpl constructs a web service for Boulder 83 func NewSelfServiceFrontEndImpl( 84 stats prometheus.Registerer, 85 clk clock.Clock, 86 logger blog.Logger, 87 requestTimeout time.Duration, 88 rac rapb.RegistrationAuthorityClient, 89 sac sapb.StorageAuthorityReadOnlyClient, 90 eec emailpb.ExporterClient, 91 unpauseHMACKey []byte, 92 zendeskClient *zendesk.Client, 93 limiter *rl.Limiter, 94 txnBuilder *rl.TransactionBuilder, 95 autoApproveOverrides bool, 96 ) (SelfServiceFrontEndImpl, error) { 97 98 // Parse the files once at startup to avoid each request causing the server 99 // to JIT parse. The pages are stored in an in-memory embed.FS to prevent 100 // unnecessary filesystem I/O on a physical HDD. 101 tmplPages, err := template.New("pages").ParseFS(dynamicFS, "templates/*", "pages/*") 102 if err != nil { 103 return SelfServiceFrontEndImpl{}, fmt.Errorf("while parsing templates: %w", err) 104 } 105 106 sfe := SelfServiceFrontEndImpl{ 107 log: logger, 108 clk: clk, 109 requestTimeout: requestTimeout, 110 ra: rac, 111 sa: sac, 112 ee: eec, 113 unpauseHMACKey: unpauseHMACKey, 114 zendeskClient: zendeskClient, 115 templatePages: tmplPages, 116 cop: http.NewCrossOriginProtection(), 117 limiter: limiter, 118 txnBuilder: txnBuilder, 119 autoApproveOverrides: autoApproveOverrides, 120 } 121 122 return sfe, nil 123 } 124 125 // wrapWithTimeout wraps an http.Handler with a timeout handler. 126 func (sfe *SelfServiceFrontEndImpl) wrapWithTimeout(h http.Handler) http.Handler { 127 timeout := sfe.requestTimeout 128 if timeout <= 0 { 129 // Default to 5 minutes if no timeout is set. 130 timeout = 5 * time.Minute 131 } 132 return http.TimeoutHandler(h, timeout, "Request timed out") 133 } 134 135 // handleGet handles GET requests with timeout. 136 func (sfe *SelfServiceFrontEndImpl) handleGet(mux *http.ServeMux, path string, h http.Handler) { 137 mux.Handle(fmt.Sprintf("%s %s", http.MethodGet, path), sfe.wrapWithTimeout(h)) 138 } 139 140 // handlePost handles POST requests with timeout and Cross-Origin Protection. 141 func (sfe *SelfServiceFrontEndImpl) handlePost(mux *http.ServeMux, path string, h http.Handler) { 142 mux.Handle(fmt.Sprintf("%s %s", http.MethodPost, path), sfe.wrapWithTimeout(sfe.cop.Handler(h))) 143 } 144 145 // Handler returns an http.Handler that uses various functions for various 146 // non-ACME-specified paths. Each endpoint should have a corresponding HTML 147 // page that shares the same name as the endpoint. 148 func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler { 149 mux := http.NewServeMux() 150 151 sfs, _ := fs.Sub(staticFS, "static") 152 staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs)) 153 mux.Handle("GET /static/", staticAssetsHandler) 154 155 sfe.handleGet(mux, "/", http.HandlerFunc(sfe.Index)) 156 sfe.handleGet(mux, "/build", http.HandlerFunc(sfe.BuildID)) 157 158 // Unpause 159 sfe.handleGet(mux, unpause.GetForm, http.HandlerFunc(sfe.UnpauseForm)) 160 sfe.handlePost(mux, unpausePostForm, http.HandlerFunc(sfe.UnpauseSubmit)) 161 sfe.handleGet(mux, unpauseStatus, http.HandlerFunc(sfe.UnpauseStatus)) 162 163 // Rate Limit Override Requests 164 if sfe.zendeskClient != nil { 165 sfe.handleGet(mux, overridesNewOrdersPerAccount, sfe.makeOverrideRequestFormHandler( 166 newOrdersPerAccountForm, rl.NewOrdersPerAccount.String(), rl.NewOrdersPerAccount.String()), 167 ) 168 // CertificatesPerDomain has two forms, one for DNS names and one 169 // for IP addresses, we differentiate them by appending a suffix to 170 // the rate limit name. 171 sfe.handleGet(mux, overridesCertificatesPerDomain, sfe.makeOverrideRequestFormHandler( 172 certificatesPerDomainForm, rl.CertificatesPerDomain.String()+perDNSNameSuffix, rl.CertificatesPerDomain.String()), 173 ) 174 sfe.handleGet(mux, overridesCertificatesPerIP, sfe.makeOverrideRequestFormHandler( 175 certificatesPerIPForm, rl.CertificatesPerDomain.String()+perIPSuffix, rl.CertificatesPerDomain.String()), 176 ) 177 sfe.handleGet(mux, overridesCertificatesPerDomainPerAccount, sfe.makeOverrideRequestFormHandler( 178 certificatesPerDomainPerAccountForm, rl.CertificatesPerDomainPerAccount.String(), rl.CertificatesPerDomainPerAccount.String()), 179 ) 180 sfe.handleGet(mux, overridesAutoApprovedSuccess, http.HandlerFunc(sfe.overrideAutoApprovedSuccessHandler)) 181 sfe.handleGet(mux, overridesRequestSubmittedSuccess, http.HandlerFunc(sfe.overrideRequestSubmittedSuccessHandler)) 182 sfe.handlePost(mux, overridesValidateField, http.HandlerFunc(sfe.validateOverrideFieldHandler)) 183 sfe.handlePost(mux, overridesSubmitRequest, http.HandlerFunc(sfe.submitOverrideRequestHandler)) 184 } 185 186 return measured_http.New(mux, sfe.clk, stats, oTelHTTPOptions...) 187 } 188 189 // renderTemplate takes the name of an HTML template and optional dynamicData 190 // which are rendered and served back to the client via the response writer. 191 func (sfe *SelfServiceFrontEndImpl) renderTemplate(w http.ResponseWriter, filename string, dynamicData any) { 192 if len(filename) == 0 { 193 http.Error(w, "Template page does not exist", http.StatusInternalServerError) 194 return 195 } 196 197 w.Header().Set("Content-Type", "text/html; charset=utf-8") 198 err := sfe.templatePages.ExecuteTemplate(w, filename, dynamicData) 199 if err != nil { 200 sfe.log.Warningf("template %q execute failed: %s", filename, err) 201 http.Error(w, err.Error(), http.StatusInternalServerError) 202 } 203 } 204 205 // Index is the homepage of the SFE 206 func (sfe *SelfServiceFrontEndImpl) Index(response http.ResponseWriter, request *http.Request) { 207 sfe.renderTemplate(response, "index.html", nil) 208 } 209 210 // BuildID tells the requester what boulder build version is running. 211 func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, request *http.Request) { 212 response.Header().Set("Content-Type", "text/plain") 213 response.WriteHeader(http.StatusOK) 214 detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime()) 215 if _, err := fmt.Fprintln(response, detailsString); err != nil { 216 sfe.log.Warningf("Could not write response: %s", err) 217 } 218 } 219 220 // UnpauseForm allows a requester to unpause their account via a form present on 221 // the page. The Subscriber's client will receive a log line emitted by the WFE 222 // which contains a URL pre-filled with a JWT that will populate a hidden field 223 // in this form. 224 func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) { 225 incomingJWT := request.URL.Query().Get("jwt") 226 227 accountID, idents, err := sfe.parseUnpauseJWT(incomingJWT) 228 if err != nil { 229 if errors.Is(err, jwt.ErrExpired) { 230 // JWT expired before the Subscriber visited the unpause page. 231 sfe.unpauseTokenExpired(response) 232 return 233 } 234 if errors.Is(err, unpause.ErrMalformedJWT) { 235 // JWT is malformed. This could happen if the Subscriber failed to 236 // copy the entire URL from their logs. 237 sfe.unpauseRequestMalformed(response) 238 return 239 } 240 sfe.unpauseFailed(response) 241 return 242 } 243 244 // If any of these values change, ensure any relevant pages in //sfe/pages/ 245 // are also updated. 246 type tmplData struct { 247 PostPath string 248 JWT string 249 AccountID int64 250 Idents []string 251 } 252 253 // Present the unpause form to the Subscriber. 254 sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, accountID, idents}) 255 } 256 257 // UnpauseSubmit serves a page showing the result of the unpause form submission. 258 // CSRF is not addressed because a third party causing submission of an unpause 259 // form is not harmful. 260 func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) { 261 incomingJWT := request.URL.Query().Get("jwt") 262 263 accountID, _, err := sfe.parseUnpauseJWT(incomingJWT) 264 if err != nil { 265 if errors.Is(err, jwt.ErrExpired) { 266 // JWT expired before the Subscriber could click the unpause button. 267 sfe.unpauseTokenExpired(response) 268 return 269 } 270 if errors.Is(err, unpause.ErrMalformedJWT) { 271 // JWT is malformed. This should never happen if the request came 272 // from our form. 273 sfe.unpauseRequestMalformed(response) 274 return 275 } 276 sfe.unpauseFailed(response) 277 return 278 } 279 280 unpaused, err := sfe.ra.UnpauseAccount(request.Context(), &rapb.UnpauseAccountRequest{ 281 RegistrationID: accountID, 282 }) 283 if err != nil { 284 sfe.unpauseFailed(response) 285 return 286 } 287 288 // Redirect to the unpause status page with the count of unpaused 289 // identifiers. 290 params := url.Values{} 291 params.Add("count", fmt.Sprintf("%d", unpaused.Count)) 292 http.Redirect(response, request, unpauseStatus+"?"+params.Encode(), http.StatusFound) 293 } 294 295 func (sfe *SelfServiceFrontEndImpl) unpauseRequestMalformed(response http.ResponseWriter) { 296 sfe.renderTemplate(response, "unpause-invalid-request.html", nil) 297 } 298 299 func (sfe *SelfServiceFrontEndImpl) unpauseTokenExpired(response http.ResponseWriter) { 300 sfe.renderTemplate(response, "unpause-expired.html", nil) 301 } 302 303 type unpauseStatusTemplate struct { 304 Successful bool 305 Limit int64 306 Count int64 307 } 308 309 func (sfe *SelfServiceFrontEndImpl) unpauseFailed(response http.ResponseWriter) { 310 sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: false}) 311 } 312 313 func (sfe *SelfServiceFrontEndImpl) unpauseSuccessful(response http.ResponseWriter, count int64) { 314 sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{ 315 Successful: true, 316 Limit: unpause.RequestLimit, 317 Count: count}, 318 ) 319 } 320 321 // UnpauseStatus displays a success message to the Subscriber indicating that 322 // their account has been unpaused. 323 func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter, request *http.Request) { 324 if request.Method != http.MethodHead && request.Method != http.MethodGet { 325 response.Header().Set("Access-Control-Allow-Methods", "GET, HEAD") 326 response.WriteHeader(http.StatusMethodNotAllowed) 327 return 328 } 329 330 count, err := strconv.ParseInt(request.URL.Query().Get("count"), 10, 64) 331 if err != nil || count < 0 { 332 sfe.unpauseFailed(response) 333 return 334 } 335 336 sfe.unpauseSuccessful(response, count) 337 } 338 339 // parseUnpauseJWT extracts and returns the subscriber's registration ID and a 340 // slice of paused identifiers from the claims. If the JWT cannot be parsed or 341 // is otherwise invalid, an error is returned. If the JWT is missing or 342 // malformed, unpause.ErrMalformedJWT is returned. 343 func (sfe *SelfServiceFrontEndImpl) parseUnpauseJWT(incomingJWT string) (int64, []string, error) { 344 if incomingJWT == "" || len(strings.Split(incomingJWT, ".")) != 3 { 345 // JWT is missing or malformed. This could happen if the Subscriber 346 // failed to copy the entire URL from their logs. This should never 347 // happen if the request came from our form. 348 return 0, nil, unpause.ErrMalformedJWT 349 } 350 351 claims, err := unpause.RedeemJWT(incomingJWT, sfe.unpauseHMACKey, unpause.APIVersion, sfe.clk) 352 if err != nil { 353 return 0, nil, err 354 } 355 356 account, convErr := strconv.ParseInt(claims.Subject, 10, 64) 357 if convErr != nil { 358 // This should never happen as this was just validated by the call to 359 // unpause.RedeemJWT(). 360 return 0, nil, errors.New("failed to parse account ID from JWT") 361 } 362 363 return account, strings.Split(claims.I, ","), nil 364 }