package auth import ( "encoding/hex" "errors" "net/http" "os" "github.com/gorilla/csrf" ) // ErrCSRFKeyInvalid is returned by LoadKeyFromEnv when SESSION_SECRET is // missing or decodes to a length other than 32 bytes. var ErrCSRFKeyInvalid = errors.New("SESSION_SECRET must be a 64-char hex string encoding 32 bytes; generate with: openssl rand -hex 32") // LoadKeyFromEnv reads SESSION_SECRET from the environment, hex-decodes it, // and validates that the result is exactly 32 bytes. Returns ErrCSRFKeyInvalid // for a missing, non-hex, or wrong-length value. The caller (cmd/web/main.go) // should log.Fatalf on error. func LoadKeyFromEnv() ([]byte, error) { raw := os.Getenv("SESSION_SECRET") if raw == "" { return nil, ErrCSRFKeyInvalid } key, err := hex.DecodeString(raw) if err != nil { return nil, ErrCSRFKeyInvalid } if len(key) != 32 { return nil, ErrCSRFKeyInvalid } return key, nil } // Mount returns a gorilla/csrf middleware configured with the locked options // from CONTEXT D-14 / D-24: // // - csrf.Secure(env != "dev"): sets the Secure flag on the _gorilla_csrf // cookie in all environments except "dev" (plain-HTTP local development). // - csrf.SameSite(csrf.SameSiteLaxMode): SameSite=Lax interim defense. // - csrf.Path("/"): the CSRF cookie is scoped to the entire site. // - csrf.FieldName("_csrf"): the hidden form field name (matches @ui.CSRFField). // - csrf.RequestHeader("X-CSRF-Token"): accepted header for HTMX hx-headers usage. // // The middleware is mounted AFTER auth.ResolveSession and BEFORE any route // group (D-24, Pitfall 7). // // When env == "dev", requests are additionally marked as plaintext HTTP via // csrf.PlaintextHTTPRequest so gorilla/csrf skips the Referer-based origin // check (which only applies to TLS). This allows local development and // integration tests running over plain HTTP to function correctly. // // trustedOrigins is an optional list of additional trusted origins (used in // tests to allow localhost requests without a Referer header). // In production, pass nil — SameSite=Lax and the CSRF cookie handle the defense. func Mount(env string, key []byte, trustedOrigins ...string) func(http.Handler) http.Handler { isDev := env == "dev" || env == "development" opts := []csrf.Option{ csrf.Secure(!isDev), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), csrf.FieldName("_csrf"), csrf.RequestHeader("X-CSRF-Token"), } if len(trustedOrigins) > 0 { opts = append(opts, csrf.TrustedOrigins(trustedOrigins)) } csrfMiddleware := csrf.Protect(key, opts...) // In dev mode, mark every request as plaintext HTTP so gorilla/csrf skips // the Referer-based TLS origin check. This is safe: dev mode already has // Secure=false on the cookie, and SameSite=Lax provides interim protection. if isDev { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Tag the request as plaintext HTTP before csrf.Protect sees it. r = csrf.PlaintextHTTPRequest(r) csrfMiddleware(next).ServeHTTP(w, r) }) } } return csrfMiddleware }