textmachine/platform/internal/auth/csrf.go

73 lines
3.5 KiB
Go

package auth
import (
"fmt"
"net/http"
)
// ClientHeader is the header a browser client must send with every unsafe cookie-authenticated
// request. Any value; its PRESENCE is the assertion.
const ClientHeader = "X-TM-Client"
// CSRF guards the cookie path and only it: a Bearer token is not ambient authority, so no
// cross-site page can make a browser attach one.
//
// Two layers, both cheap:
//
// - http.CrossOriginProtection (stdlib, Go 1.25+) — Sec-Fetch-Site with an Origin/Host fallback.
// This is the mechanism STACK_DECISIONS §5 describes, and it now ships with the toolchain, so
// we do not hand-roll it.
// - a required custom header on unsafe requests that carry the session cookie. The stdlib check
// ALLOWS a request bearing neither Sec-Fetch-Site nor Origin, on the reasoning that it is not
// a browser. A pre-2023 browser posting a cross-site <form> is exactly that case, and
// POST /books takes multipart/form-data — a form-reachable content type that triggers no
// preflight. A plain form cannot set a custom header; a fetch() from our own origin can.
//
// trustedOrigins are additional origins allowed to make unsafe requests (a separately deployed
// frontend); empty means same-origin only.
//
// The two deniers are ONE status (403) and two different facts — a request from an origin this
// deployment does not accept, and a same-origin request that did not carry the marker header — and
// the contract keeps them apart in the open second level of its error code rather than in the
// closed root one. Handing this function one handler for both is what made them indistinguishable
// to a client that has to tell "your page is wrong" from "your request is wrong".
func CSRF(trustedOrigins []string, cookieName string, denyOrigin, denyHeader http.Handler) (func(http.Handler) http.Handler, error) {
p := http.NewCrossOriginProtection()
p.SetDenyHandler(denyOrigin)
for _, o := range trustedOrigins {
if err := p.AddTrustedOrigin(o); err != nil {
return nil, fmt.Errorf("auth: trusted origin %q: %w", o, err)
}
}
return func(next http.Handler) http.Handler {
return p.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cookieUnsafe(r, cookieName) && r.Header.Get(ClientHeader) == "" {
denyHeader.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
}))
}, nil
}
// cookieUnsafe reports a state-changing request presented by cookie. It reads the cookie directly
// rather than the principal: this check runs BEFORE authentication, so that a forged request is
// refused without touching the session table.
func cookieUnsafe(r *http.Request, cookieName string) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
}
// A well-formed Bearer is exempt. Present PARSES it; it does not validate it — the session
// lookup does that, later — so `Bearer <nonsense>` gets the exemption too. That is safe, and
// the reason is worth naming because it is not the parsing: once an Authorization header is
// present, Present NEVER falls back to the cookie, so such a request authenticates as nothing
// and ends in 401. It cannot trade the CSRF check for the cookie's authority; it can only give
// up its own. Testing for a merely non-empty header would be weaker still — `Authorization: x`
// would let the caller pick which layer applies (PD-33, PD-50).
if _, _, ok := Present(r, ""); ok && r.Header.Get("Authorization") != "" {
return false
}
c, err := r.Cookie(cookieName)
return err == nil && c.Value != ""
}