package webui import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" ) // csrfSessionCookieNames are tried in order to find whatever session-identifying // cookie the current request carries — the full admin session, the full mailbox // session, or either's pending-MFA cookie (covers the MFA step of login, which // happens before the full session exists but after a pending cookie is set). The // bare initial login POST, before any of these cookies exist yet, has no source // token and so is intentionally not CSRF-checked — standard practice, since there's // no session yet for a forged request to act against. var csrfSessionCookieNames = []string{ sessionCookieName, mailboxSessionCookieName, pendingMFACookieName, mailboxPendingMFACookieName, } func csrfSourceToken(r *http.Request) (string, bool) { for _, name := range csrfSessionCookieNames { if c, err := r.Cookie(name); err == nil && c.Value != "" { return c.Value, true } } return "", false } // csrfTokenFor derives this request's expected CSRF token: an HMAC over whatever // session-identifying cookie is present, keyed by the app secret. Deterministic and // unstored — recomputed fresh on both render (render.go injects it into every page) // and validation (CSRFProtect below), so there's no server-side token table to // manage or expire. func (a *App) csrfTokenFor(r *http.Request) string { token, ok := csrfSourceToken(r) if !ok { return "" } mac := hmac.New(sha256.New, a.appSecret) mac.Write([]byte(token)) return hex.EncodeToString(mac.Sum(nil)) } // csrfProtectedMethods are the only ones CSRFProtect checks — GET/HEAD/OPTIONS never // mutate state in this app (see the M2 fix that removed the one exception that used // to exist) so they're exempt, matching standard CSRF-defense scope. var csrfProtectedMethods = map[string]bool{http.MethodPost: true, http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true} // CSRFProtect rejects state-changing requests whose csrf_token doesn't match what // csrfTokenFor computes for the request's own session cookie. Every authenticated // HTML form gets the token auto-injected as a hidden field, and every same-origin // fetch() call gets it auto-attached as an X-CSRF-Token header — both via the shared // csrf_script.html partial parsed into every page (see render.go/loadTemplates) — // so no individual handler or template needed to change for this to apply // uniformly across the whole app, admin and webmail alike. func (a *App) CSRFProtect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !csrfProtectedMethods[r.Method] { next.ServeHTTP(w, r) return } expected := a.csrfTokenFor(r) if expected == "" { // No session cookie at all — an unauthenticated route (e.g. the login POST // itself). Nothing to protect yet. next.ServeHTTP(w, r) return } got := r.Header.Get("X-CSRF-Token") if got == "" { // Only fall back to parsing the body if the header wasn't already present — // keeps the common fetch()-with-header path from ever triggering an implicit // multipart parse here (the handler still parses it normally afterward). got = r.FormValue("csrf_token") } if got == "" || !hmac.Equal([]byte(got), []byte(expected)) { http.Error(w, "Forbidden: missing or invalid CSRF token", http.StatusForbidden) return } next.ServeHTTP(w, r) }) }