67 lines
3 KiB
Go
67 lines
3 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.
|
|
func CSRF(trustedOrigins []string, cookieName string, deny http.Handler) (func(http.Handler) http.Handler, error) {
|
|
p := http.NewCrossOriginProtection()
|
|
p.SetDenyHandler(deny)
|
|
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) == "" {
|
|
deny.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 != ""
|
|
}
|