xtablo-source/backend/internal/auth/csrf.go
2026-05-15 19:57:46 +02:00

84 lines
3.1 KiB
Go

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
}