Name the cause on the page for a refused form (closes #358)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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 was merged in pull request #365.
This commit is contained in:
1 parent
9bccaaf85a
commit
b32077c380
7 files changed
+343
-6
No files matched your search
@@ -403,6 +403,10 @@ instead. Plain HTTP is for development on the browser's own machine. The login
|
|||||||
session cookie is always marked `Secure`. Over plain HTTP a browser keeps such a
|
session cookie is always marked `Secure`. Over plain HTTP a browser keeps such a
|
||||||
cookie only for its own machine (`localhost`), if at all.
|
cookie only for its own machine (`localhost`), if at all.
|
||||||
|
|
||||||
|
A form refused with 403 gets a page that names the cause and what to do about
|
||||||
|
it. pixa logs a `form refused` warning with the request ID and the form-token
|
||||||
|
library's reason, such as `origin invalid`.
|
||||||
|
|
||||||
The image routes answer the errors listed for them with a JSON body holding
|
The image routes answer the errors listed for them with a JSON body holding
|
||||||
`error`, `status` and `timestamp`.
|
`error`, `status` and `timestamp`.
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug, slog.New(slog.DiscardHandler)),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func newBodyLimitTestRouter(
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
sessMgr: session.NewManager(keys.SessionHashKey, keys.SessionBlockKey),
|
||||||
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
encGen: encurl.NewGenerator(keys.EncryptedURLKey),
|
||||||
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug),
|
csrfProtect: newCSRFProtect(keys.CSRFKey, cfg.Debug, slog.New(slog.DiscardHandler)),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
+106
-2
@@ -1,18 +1,43 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
"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
|
// newCSRFProtect builds the CSRF-protection middleware for the
|
||||||
// state-mutating HTML form routes. The token lives in its own cookie,
|
// state-mutating HTML form routes. The token lives in its own cookie,
|
||||||
// independent of the session cookie, so it also protects the login POST
|
// 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
|
// When plaintext is true (local HTTP development), requests are marked
|
||||||
// plaintext so the library neither demands an https Referer nor sets a
|
// 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
|
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
||||||
// the library enforces its https Referer origin check.
|
// the library enforces its https Referer origin check.
|
||||||
func newCSRFProtect(
|
func newCSRFProtect(
|
||||||
key [seal.KeySize]byte, plaintext bool,
|
key [seal.KeySize]byte, plaintext bool, log *slog.Logger,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
protect := csrf.Protect(
|
protect := csrf.Protect(
|
||||||
key[:],
|
key[:],
|
||||||
csrf.Path("/"),
|
csrf.Path("/"),
|
||||||
csrf.Secure(!plaintext),
|
csrf.Secure(!plaintext),
|
||||||
csrf.SameSite(csrf.SameSiteStrictMode),
|
csrf.SameSite(csrf.SameSiteStrictMode),
|
||||||
|
csrf.ErrorHandler(handleFormRefused(plaintext, log)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if !plaintext {
|
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
|
// CSRF returns the CSRF-protection middleware for the login and generator
|
||||||
// form routes.
|
// form routes.
|
||||||
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refusedFormRequestID is the request ID of the requests postRefusedForm
|
||||||
|
// sends, which the warning for a refused form must carry.
|
||||||
|
const refusedFormRequestID = "test-request-id"
|
||||||
|
|
||||||
|
// TestFormRefusedNamesCause verifies that a form the CSRF middleware refuses
|
||||||
|
// answers 403 with a page naming the cause, and logs a warning with the
|
||||||
|
// request ID and the middleware's reason.
|
||||||
|
func TestFormRefusedNamesCause(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
debug bool
|
||||||
|
origin string
|
||||||
|
referer string
|
||||||
|
wantCause string
|
||||||
|
wantReason error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain HTTP origin with debug off",
|
||||||
|
origin: "http://example.com",
|
||||||
|
wantCause: causePlainHTTP,
|
||||||
|
wantReason: csrf.ErrBadOrigin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain HTTP referer with debug off",
|
||||||
|
referer: "http://example.com/",
|
||||||
|
wantCause: causePlainHTTP,
|
||||||
|
wantReason: csrf.ErrBadReferer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "origin on another host",
|
||||||
|
origin: "https://proxy.example.net",
|
||||||
|
wantCause: causeOtherHost,
|
||||||
|
wantReason: csrf.ErrBadOrigin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "referer on another host",
|
||||||
|
referer: "https://proxy.example.net/",
|
||||||
|
wantCause: causeOtherHost,
|
||||||
|
wantReason: csrf.ErrBadReferer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS origin with debug on",
|
||||||
|
debug: true,
|
||||||
|
origin: "https://example.com",
|
||||||
|
wantCause: causeHTTPSWithDebug,
|
||||||
|
wantReason: csrf.ErrBadOrigin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no origin or referer with debug off",
|
||||||
|
wantCause: causeNoReferer,
|
||||||
|
wantReason: csrf.ErrNoReferer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
debug: true,
|
||||||
|
origin: "http://example.com",
|
||||||
|
wantCause: causeStaleToken,
|
||||||
|
wantReason: csrf.ErrNoToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec, logLine := postRefusedForm(t, tt.debug, tt.origin, tt.referer,
|
||||||
|
nil, "")
|
||||||
|
checkFormRefused(t, rec, logLine, tt.wantCause, tt.wantReason)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormRefusedNamesCauseForForeignToken verifies that a form whose token
|
||||||
|
// does not match its CSRF cookie answers 403 with a page naming
|
||||||
|
// causeStaleToken, and logs a warning with the request ID and the
|
||||||
|
// middleware's reason.
|
||||||
|
func TestFormRefusedNamesCauseForForeignToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The router and postRefusedForm derive their CSRF key from
|
||||||
|
// testSigningKey, so cookiesA is valid there and only tokenB is wrong.
|
||||||
|
_, router := newCSRFTestRouter(t)
|
||||||
|
cookiesA, _ := csrfCredentials(t, router, nil)
|
||||||
|
_, tokenB := csrfCredentials(t, router, nil)
|
||||||
|
|
||||||
|
rec, logLine := postRefusedForm(t, true, "http://example.com", "",
|
||||||
|
cookiesA, tokenB)
|
||||||
|
checkFormRefused(t, rec, logLine, causeStaleToken, csrf.ErrBadToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postRefusedForm sends a POST with the given cookies to the CSRF
|
||||||
|
// middleware built for debug. It adds the form token and the Origin and
|
||||||
|
// Referer headers when they are not empty. It returns the response and the
|
||||||
|
// one JSON log line the middleware wrote.
|
||||||
|
func postRefusedForm(
|
||||||
|
t *testing.T, debug bool, origin, referer string,
|
||||||
|
cookies []*http.Cookie, token string,
|
||||||
|
) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
keys, err := seal.DeriveKeys(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
log := slog.New(slog.NewJSONHandler(&logs, nil))
|
||||||
|
reached := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("the refused form reached the handler behind the middleware")
|
||||||
|
})
|
||||||
|
srv := newCSRFProtect(keys.CSRFKey, debug, log)(reached)
|
||||||
|
|
||||||
|
form := url.Values{loginKeyField: {"x"}}
|
||||||
|
if token != "" {
|
||||||
|
form.Set(csrfTokenField, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(),
|
||||||
|
middleware.RequestIDKey, refusedFormRequestID)
|
||||||
|
req := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
"http://example.com/", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if origin != "" {
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if referer != "" {
|
||||||
|
req.Header.Set("Referer", referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var logLine map[string]any
|
||||||
|
|
||||||
|
err = json.Unmarshal(logs.Bytes(), &logLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding log line %q: %v", logs.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec, logLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFormRefused checks that rec is a 403 page naming wantCause and that
|
||||||
|
// logLine is a warning carrying the request ID and wantReason.
|
||||||
|
func checkFormRefused(
|
||||||
|
t *testing.T, rec *httptest.ResponseRecorder, logLine map[string]any,
|
||||||
|
wantCause string, wantReason error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
t.Logf("body: %s", body)
|
||||||
|
|
||||||
|
if !strings.Contains(body, template.HTMLEscapeString(wantCause)) {
|
||||||
|
t.Errorf("page does not name the cause %q", wantCause)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("log line: %v", logLine)
|
||||||
|
|
||||||
|
if logLine["level"] != "WARN" || logLine["msg"] != "form refused" {
|
||||||
|
t.Errorf("log line = %v, want a WARN \"form refused\"", logLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logLine["request_id"] != refusedFormRequestID {
|
||||||
|
t.Errorf("logged request_id = %v, want %q",
|
||||||
|
logLine["request_id"], refusedFormRequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logLine["reason"] != wantReason.Error() {
|
||||||
|
t.Errorf("logged reason = %v, want %q", logLine["reason"], wantReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,14 +56,16 @@ type Handlers struct {
|
|||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(_ fx.Lifecycle, params Params) *Handlers {
|
func New(_ fx.Lifecycle, params Params) *Handlers {
|
||||||
|
log := params.Logger.Get()
|
||||||
|
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: log,
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
imgSvc: params.ImageService,
|
imgSvc: params.ImageService,
|
||||||
sessMgr: params.SessionManager,
|
sessMgr: params.SessionManager,
|
||||||
encGen: params.EncryptedURLGenerator,
|
encGen: params.EncryptedURLGenerator,
|
||||||
csrfProtect: newCSRFProtect(params.Keys.CSRFKey, params.Config.Debug),
|
csrfProtect: newCSRFProtect(params.Keys.CSRFKey, params.Config.Debug, log),
|
||||||
signer: signature.New(params.Config.SigningKey),
|
signer: signature.New(params.Config.SigningKey),
|
||||||
allowlist: allowlist.New(params.Config.AllowlistHosts),
|
allowlist: allowlist.New(params.Config.AllowlistHosts),
|
||||||
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pixa - Form Refused</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Form Refused</h1>
|
||||||
|
|
||||||
|
<div class="error">
|
||||||
|
{{.Cause}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="/">Back to the start page</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user