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  }