Name the cause on the page for a refused form (closes #358)
check / check (push) Waiting to run
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
This commit is contained in:
1 parent
851a374e53
commit
1e77b8d4f2
7 files changed
+343
-6
No files matched your search
+106
-2
@@ -1,18 +1,43 @@
|
||||
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.
|
||||
// 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
|
||||
@@ -20,13 +45,14 @@ import (
|
||||
// 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,
|
||||
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 {
|
||||
@@ -42,6 +68,84 @@ func newCSRFProtect(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in new issue
Block a user