package handlers import ( "bytes" "errors" "html/template" "log/slog" "net/http" "net/url" "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/csrf" "sneak.berlin/go/pixa/internal/seal" "sneak.berlin/go/pixa/internal/templates" ) // The causes the form-refused page names, each with what to do about it. const ( causePlainHTTP = "This page was opened over plain HTTP while debug is " + "off. Open it over HTTPS, or turn debug on to use plain HTTP on " + "your own machine." causeHTTPSWithDebug = "This page was opened over HTTPS while debug is " + "on. Turn debug off, or open the page over plain HTTP." causeOtherHost = "The form was sent from another host than the one in " + "the request's Host header. A reverse proxy in front of pixa must " + "pass the Host header on unchanged." causeNoReferer = "The browser sent neither an Origin nor a Referer " + "header, and over HTTPS pixa needs one to check where the form " + "came from. Let the browser send the Referer header to this site." causeStaleToken = "The form token is missing or out of date. Reload " + "the page and submit the form again." ) // newCSRFProtect builds the CSRF-protection middleware for the // state-mutating HTML form routes. The token lives in its own cookie, // independent of the session cookie, so it also protects the login POST // where no session exists yet (login CSRF). key signs that cookie. A // refused form gets the page respondFormRefused renders, and log gets a // warning naming the cause. // // When plaintext is true (local HTTP development), requests are marked // plaintext so the library neither demands an https Referer nor sets a // Secure cookie the browser would withhold over http. In production the // service runs behind a TLS-terminating proxy, so plaintext is false and // the library enforces its https Referer origin check. func newCSRFProtect( key [seal.KeySize]byte, plaintext bool, log *slog.Logger, ) func(http.Handler) http.Handler { protect := csrf.Protect( key[:], csrf.Path("/"), csrf.Secure(!plaintext), csrf.SameSite(csrf.SameSiteStrictMode), csrf.ErrorHandler(respondFormRefused(plaintext, log)), ) if !plaintext { return protect } return func(next http.Handler) http.Handler { protected := protect(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r)) }) } } // respondFormRefused returns the handler the CSRF middleware calls when it // refuses a request. It logs the library's reason with the request ID and // answers 403 with a page naming the cause and what to do. A reason that is // not a refusal, such as a failure to make a token, answers 500. func respondFormRefused(plaintext bool, log *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reason := csrf.FailureReason(r) requestID := middleware.GetReqID(r.Context()) cause := formRefusedCause(r, reason, plaintext) if cause == "" { log.Error("CSRF middleware failed", "request_id", requestID, "error", reason) http.Error(w, "Internal server error", http.StatusInternalServerError) return } log.Warn("form refused", "request_id", requestID, "reason", reason) var page bytes.Buffer err := templates.Render(&page, "form_refused.html", struct{ Cause string }{Cause: cause}) if err != nil { log.Error("failed to render form refused template", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) _, err = page.WriteTo(w) if err != nil { log.Error("failed to write form refused page", "error", err) } }) } // formRefusedCause returns the cause the form-refused page names for the // CSRF middleware's reason, or "" when the reason is not a refusal. func formRefusedCause(r *http.Request, reason error, plaintext bool) string { switch { case errors.Is(reason, csrf.ErrNoToken), errors.Is(reason, csrf.ErrBadToken): return causeStaleToken case errors.Is(reason, csrf.ErrNoReferer): return causeNoReferer case errors.Is(reason, csrf.ErrBadOrigin): return pageAddressCause(r.Header.Get("Origin"), r.Host, plaintext) case errors.Is(reason, csrf.ErrBadReferer): return pageAddressCause(r.Referer(), r.Host, plaintext) default: return "" } } // pageAddressCause returns the cause for a form whose page address, the // Origin or Referer header, the CSRF middleware refused. The middleware // expects the request's own host, with https when plaintext is false and // http when it is true. So on the request's own host, only the scheme was // wrong. func pageAddressCause(pageAddress, host string, plaintext bool) string { page, err := url.Parse(pageAddress) if err != nil || page.Host != host { return causeOtherHost } if plaintext { return causeHTTPSWithDebug } return causePlainHTTP } // CSRF returns the CSRF-protection middleware for the login and generator // form routes. func (s *Handlers) CSRF() func(http.Handler) http.Handler { return s.csrfProtect } // csrfField returns the hidden form input carrying the CSRF token for the // given request, to be embedded verbatim in a rendered form. func csrfField(r *http.Request) template.HTML { return csrf.TemplateField(r) }