check / check (push) Waiting to run
The CSRF middleware answered a refused login or generator form with the library's bare text, such as "Forbidden - origin invalid". It now answers 403 with a page that names the cause and what to do, and logs a warning with the request ID and the library's reason. A page opened over plain HTTP with debug off and a form from another host both fail the origin check; the page tells them apart by the host in the Origin or Referer header. Model: opus-5-5
160 lines
5.3 KiB
Go
160 lines
5.3 KiB
Go
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 is answered by the handler that handleFormRefused returns.
|
|
// log gets a warning with the library's reason, such as "origin invalid".
|
|
//
|
|
// 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(handleFormRefused(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))
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleFormRefused 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 handleFormRefused(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)
|
|
}
|