mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
113 lines
3.8 KiB
Go
113 lines
3.8 KiB
Go
package admin
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
csrfHeaderName = "X-CSRF-Token"
|
|
csrfFormField = "csrf_token"
|
|
)
|
|
|
|
// generateCSRFToken returns a cryptographically random token.
|
|
// 32 bytes (256 bits) base64url-encoded, matching the session token format.
|
|
func generateCSRFToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generate csrf token: %w", err)
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// requireCSRF validates a per-session CSRF token on state-mutating requests.
|
|
// Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply
|
|
// the token via the X-CSRF-Token header (htmx path) or the csrf_token form
|
|
// field on application/x-www-form-urlencoded bodies (plain-form path).
|
|
// Multipart bodies are rejected unless the header is present — this avoids
|
|
// consuming a multipart body in middleware and stepping on per-handler size
|
|
// limits such as http.MaxBytesReader.
|
|
//
|
|
// Must run after requireOwner so a session is present on the request context.
|
|
func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
session := getSessionFromContext(r.Context())
|
|
if session == nil || session.CSRFToken == "" {
|
|
slog.Warn("CSRF check failed: no session token",
|
|
"path", r.URL.Path, "method", r.Method)
|
|
csrfReject(w, r, "missing session")
|
|
return
|
|
}
|
|
|
|
got := r.Header.Get(csrfHeaderName)
|
|
if got == "" {
|
|
contentType := r.Header.Get("Content-Type")
|
|
// Split off any ;boundary=... suffix before comparing.
|
|
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
|
|
contentType = contentType[:idx]
|
|
}
|
|
contentType = strings.TrimSpace(strings.ToLower(contentType))
|
|
switch contentType {
|
|
case "application/x-www-form-urlencoded":
|
|
if err := r.ParseForm(); err == nil {
|
|
got = r.PostFormValue(csrfFormField)
|
|
}
|
|
case "multipart/form-data":
|
|
// Parse multipart with a small limit just to read the CSRF
|
|
// field. 32 KB is enough for the token + file metadata without
|
|
// loading uploaded file data into memory — Go stores the file
|
|
// portion beyond maxMemory on disk.
|
|
const csrfMultipartMaxMem = 32 << 10 // 32 KB
|
|
if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil {
|
|
got = r.FormValue(csrfFormField)
|
|
}
|
|
}
|
|
}
|
|
|
|
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
|
|
slog.Warn("CSRF token mismatch",
|
|
"path", r.URL.Path,
|
|
"method", r.Method,
|
|
"did", session.DID,
|
|
"provided", got != "")
|
|
csrfReject(w, r, "token mismatch")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// csrfReject returns a 403. For htmx requests it surfaces a toast via the
|
|
// standard HX-Trigger channel so the page-level error handler can announce
|
|
// the failure; for plain browsers it's a text response.
|
|
func csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
|
|
const userMsg = "Session expired or CSRF token invalid — reload the page and try again."
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Trigger",
|
|
`{"toast":{"message":"`+userMsg+`","type":"error"}}`)
|
|
w.Header().Set("HX-Reswap", "none")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden)
|
|
}
|
|
|
|
// csrfInputHTML returns a hidden form input carrying the CSRF token, safely
|
|
// escaped for attribute context.
|
|
func csrfInputHTML(token string) template.HTML {
|
|
escaped := template.HTMLEscapeString(token)
|
|
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
|
|
}
|