···66//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'"
7788import (
99+ "bytes"
910 "context"
1011 "crypto/rand"
1112 "embed"
···5455 }
5556}
56575757-// AdminSession represents an authenticated admin session
5858+// AdminSession represents an authenticated admin session. UserAgent and
5959+// IPPrefix are captured at login and rechecked on every request — a stolen
6060+// token replayed from a different browser or network prefix is rejected and
6161+// the session is torn down. Binding at /24 (IPv4) / /64 (IPv6) tolerates
6262+// DHCP renewals within a prefix without inviting cross-network replay.
5863type AdminSession struct {
5959- DID string
6060- Handle string
6464+ DID string
6565+ Handle string
6666+ CSRFToken string
6767+ CreatedAt time.Time
6868+ UserAgent string
6969+ IPPrefix string
6170}
7171+7272+// sessionTTL is the server-side lifetime of an admin session. Sessions older
7373+// than this are treated as expired regardless of cookie state.
7474+const sessionTTL = 24 * time.Hour
62756376// AdminUI manages the admin web interface
6477type AdminUI struct {
···6982 templates map[string]*template.Template
7083 config AdminConfig
71848585+ // secureCookies indicates cookies should carry the Secure flag regardless
8686+ // of per-request proxy header state. Set at init from PublicURL scheme so
8787+ // a misconfigured reverse proxy can't silently drop Secure.
8888+ secureCookies bool
8989+7290 // In-memory session storage (single user, no persistence needed)
7391 sessions map[string]*AdminSession
7492 sessionsMu sync.RWMutex
···139157 }
140158141159 ui := &AdminUI{
142142- pds: holdPDS,
143143- quotaMgr: quotaMgr,
144144- gc: garbageCollector,
145145- clientApp: clientApp,
146146- templates: templates,
147147- config: cfg,
148148- sessions: make(map[string]*AdminSession),
160160+ pds: holdPDS,
161161+ quotaMgr: quotaMgr,
162162+ gc: garbageCollector,
163163+ clientApp: clientApp,
164164+ templates: templates,
165165+ config: cfg,
166166+ secureCookies: strings.HasPrefix(cfg.PublicURL, "https://"),
167167+ sessions: make(map[string]*AdminSession),
149168 }
150169151170 slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL)
···155174156175// Session management
157176158158-func (ui *AdminUI) createSession(did, handle string) (string, error) {
177177+func (ui *AdminUI) createSession(did, handle, userAgent, ipPrefix string) (string, error) {
159178 b := make([]byte, 32)
160179 if _, err := rand.Read(b); err != nil {
161180 return "", fmt.Errorf("failed to create session token: %w", err)
162181 }
163182 token := base64.URLEncoding.EncodeToString(b)
164183184184+ csrfToken, err := generateCSRFToken()
185185+ if err != nil {
186186+ return "", err
187187+ }
188188+165189 ui.sessionsMu.Lock()
166166- ui.sessions[token] = &AdminSession{DID: did, Handle: handle}
190190+ ui.sessions[token] = &AdminSession{
191191+ DID: did,
192192+ Handle: handle,
193193+ CSRFToken: csrfToken,
194194+ CreatedAt: time.Now(),
195195+ UserAgent: userAgent,
196196+ IPPrefix: ipPrefix,
197197+ }
167198 ui.sessionsMu.Unlock()
168199169200 return token, nil
170201}
171202203203+// getSession returns the session for the given token, or nil if missing or
204204+// expired. Expired sessions are evicted on access to keep the in-memory map
205205+// bounded even if the user never hits logout.
172206func (ui *AdminUI) getSession(token string) *AdminSession {
173207 ui.sessionsMu.RLock()
174174- defer ui.sessionsMu.RUnlock()
175175- return ui.sessions[token]
208208+ session := ui.sessions[token]
209209+ ui.sessionsMu.RUnlock()
210210+ if session == nil {
211211+ return nil
212212+ }
213213+ if !session.CreatedAt.IsZero() && time.Since(session.CreatedAt) > sessionTTL {
214214+ ui.sessionsMu.Lock()
215215+ delete(ui.sessions, token)
216216+ ui.sessionsMu.Unlock()
217217+ return nil
218218+ }
219219+ return session
176220}
177221178222func (ui *AdminUI) deleteSession(token string) {
···185229186230const sessionCookieName = "hold_admin_session"
187231232232+// secureForRequest returns whether the Secure flag should be set on admin
233233+// cookies for this request. True if the hold is served over HTTPS (derived
234234+// from PublicURL at init) OR the request itself is TLS-terminated or came
235235+// through a proxy that advertised https. Union rules out the case where a
236236+// reverse proxy forgets to set X-Forwarded-Proto — PublicURL is the source
237237+// of truth.
238238+func (ui *AdminUI) secureForRequest(r *http.Request) bool {
239239+ return ui.secureCookies || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
240240+}
241241+188242func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
189189- secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
243243+ // SameSite=Lax (not Strict). Strict would drop the session cookie on
244244+ // the redirect chain coming back from the OAuth provider: the browser
245245+ // classifies the post-callback navigation to /admin as not-same-site
246246+ // because the chain was initiated from the PDS, and the user lands
247247+ // back on the login page. Lax still blocks the dominant CSRF vectors
248248+ // (cross-site form POSTs, image/XHR requests) and the CSRF middleware
249249+ // covers what Lax doesn't.
190250 http.SetCookie(w, &http.Cookie{
191251 Name: sessionCookieName,
192252 Value: token,
193253 Path: "/admin",
194254 MaxAge: 86400, // 24 hours
195255 HttpOnly: true,
196196- Secure: secure,
256256+ Secure: ui.secureForRequest(r),
197257 SameSite: http.SameSiteLaxMode,
198258 })
199259}
···217277 return cookie.Value, true
218278}
219279280280+// clientIPPrefix returns a stable prefix key for the request's client IP.
281281+// /24 for IPv4, /64 for IPv6. Returns empty string if the address is
282282+// unparseable — callers treat "" as "don't bind" to avoid locking users out
283283+// behind unusual proxies (Unix sockets, tests, etc.).
284284+func clientIPPrefix(r *http.Request) string {
285285+ var host string
286286+ if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
287287+ // Leftmost entry is the original client.
288288+ if comma := strings.IndexByte(fwd, ','); comma >= 0 {
289289+ host = strings.TrimSpace(fwd[:comma])
290290+ } else {
291291+ host = strings.TrimSpace(fwd)
292292+ }
293293+ } else {
294294+ h, _, err := net.SplitHostPort(r.RemoteAddr)
295295+ if err == nil {
296296+ host = h
297297+ } else {
298298+ host = r.RemoteAddr
299299+ }
300300+ }
301301+ ip := net.ParseIP(host)
302302+ if ip == nil {
303303+ return ""
304304+ }
305305+ if v4 := ip.To4(); v4 != nil {
306306+ return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2])
307307+ }
308308+ v6 := ip.To16()
309309+ return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x",
310310+ v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7])
311311+}
312312+220313// parseTemplates loads and parses all HTML templates.
221314// Components (including layout) are parsed into a base template. Each page and
222315// partial gets its own clone of the base so that {{block}} overrides don't conflict.
···258351 template.HTMLEscapeString(name),
259352 ))
260353 },
354354+ // csrfInput emits a hidden input carrying the per-session CSRF token.
355355+ // Usage: {{ csrfInput .CSRFToken }}
356356+ "csrfInput": csrfInputHTML,
357357+ // loginError maps a slug from the login error query parameter to a
358358+ // user-friendly message. Unknown slugs produce a generic fallback so
359359+ // internal error details are never surfaced to the browser.
360360+ "loginError": loginErrorMessage,
261361 }
262362263363 // Collect template files by category
···358458 // OAuth client metadata endpoint (required for production OAuth)
359459 r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata)
360460361361- // Public auth routes
461461+ // Public auth routes. Authorize is POST-only — the handle is the user's
462462+ // identity and must not land in browser history, access logs, or
463463+ // Referer headers.
362464 r.Get("/admin/auth/login", ui.handleLogin)
363363- r.Get("/admin/auth/oauth/authorize", ui.handleAuthorize)
465465+ r.Post("/admin/auth/oauth/authorize", ui.handleAuthorize)
364466 r.Get("/admin/auth/oauth/callback", ui.handleCallback)
365467366468 // Protected routes (require owner)
367469 r.Group(func(r chi.Router) {
368470 r.Use(ui.requireOwner)
471471+ // CSRF check runs after requireOwner so the session (and thus the
472472+ // per-session token to compare against) is already on the context.
473473+ r.Use(ui.requireCSRF)
369474370475 // Single admin page (client-side tab switching)
371476 r.Get("/admin", ui.handleAdmin)
···440545 metadata.ClientName = &clientName
441546 metadata.ClientURI = &ui.config.PublicURL
442547548548+ // Encode into a buffer first so an encode failure can produce a clean
549549+ // 500 response. Writing directly to w commits the 200 header at the
550550+ // first byte, after which WriteHeader becomes a no-op.
551551+ var buf bytes.Buffer
552552+ if err := json.NewEncoder(&buf).Encode(metadata); err != nil {
553553+ slog.Error("failed to encode client metadata", "error", err, "path", r.URL.Path)
554554+ http.Error(w, "Internal server error", http.StatusInternalServerError)
555555+ return
556556+ }
557557+443558 w.Header().Set("Content-Type", "application/json")
444559 w.Header().Set("Cache-Control", "public, max-age=3600")
445445- if err := json.NewEncoder(w).Encode(metadata); err != nil {
446446- slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
447447- w.WriteHeader(http.StatusInternalServerError)
560560+ if _, err := w.Write(buf.Bytes()); err != nil {
561561+ slog.Debug("client metadata write failed", "error", err, "path", r.URL.Path)
448562 }
449563}
450564
+51-1
pkg/hold/admin/auth.go
···77 "strings"
88)
991010-// requireOwner middleware ensures the request is from the hold owner
1010+// requireOwner middleware ensures the request is from the hold owner.
1111+// Enforces three layers:
1212+// 1. Session token exists and has not expired (24h absolute TTL).
1313+// 2. Session's DID still matches captain.Owner in the PDS — if ownership
1414+// changes mid-session, the old session is torn down on the next hit.
1515+// 3. Browser User-Agent and network prefix match what was captured at
1616+// login. A mismatch means the cookie is being replayed from a
1717+// different browser or network and the session is invalidated.
1118func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
1219 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1320 // Get session cookie
···4350 return
4451 }
45525353+ // User-Agent / IP-prefix binding. Empty bound values (e.g. from
5454+ // clients with no UA, requests over Unix sockets) are not
5555+ // compared — binding opts out rather than locking the user out.
5656+ if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
5757+ slog.Warn("Admin session User-Agent mismatch — suspected token replay",
5858+ "did", session.DID)
5959+ ui.deleteSession(token)
6060+ clearSessionCookie(w)
6161+ http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
6262+ return
6363+ }
6464+ if session.IPPrefix != "" {
6565+ if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
6666+ slog.Warn("Admin session IP prefix mismatch — suspected token replay",
6767+ "did", session.DID,
6868+ "sessionPrefix", session.IPPrefix,
6969+ "requestPrefix", now)
7070+ ui.deleteSession(token)
7171+ clearSessionCookie(w)
7272+ http.Redirect(w, r, "/admin/auth/login?error=access_denied", http.StatusFound)
7373+ return
7474+ }
7575+ }
7676+4677 // Add session to context for handlers
4778 ctx := context.WithValue(r.Context(), adminContextKey{}, session)
4879 next.ServeHTTP(w, r.WithContext(ctx))
···5889 return session
5990}
60919292+// sessionDIDFromContext returns the authenticated DID for log annotation,
9393+// empty if there is no session (e.g. an early-rejected request).
9494+func sessionDIDFromContext(ctx context.Context) string {
9595+ if s := getSessionFromContext(ctx); s != nil {
9696+ return s.DID
9797+ }
9898+ return ""
9999+}
100100+61101// PageData contains common data for all admin pages
62102type PageData struct {
63103 Title string
···65105 User *AdminSession
66106 HoldDID string
67107 Flash *Flash
108108+ // CSRFToken is the per-session token that forms and htmx must echo back
109109+ // on state-mutating requests. Threaded to <body hx-headers=...> at the
110110+ // layout level; plain forms emit it via {{ csrfInput .CSRFToken }}.
111111+ CSRFToken string
68112}
6911370114// Flash represents a flash message
···78122 session := getSessionFromContext(r.Context())
79123 flash := getFlash(r, ui)
80124125125+ var csrf string
126126+ if session != nil {
127127+ csrf = session.CSRFToken
128128+ }
129129+81130 return PageData{
82131 Title: title,
83132 ActivePage: activePage,
84133 User: session,
85134 HoldDID: ui.pds.DID(),
86135 Flash: flash,
136136+ CSRFToken: csrf,
87137 }
88138}
89139
+112
pkg/hold/admin/csrf.go
···11+package admin
22+33+import (
44+ "crypto/rand"
55+ "crypto/subtle"
66+ "encoding/base64"
77+ "fmt"
88+ "html/template"
99+ "log/slog"
1010+ "net/http"
1111+ "strings"
1212+)
1313+1414+const (
1515+ csrfHeaderName = "X-CSRF-Token"
1616+ csrfFormField = "csrf_token"
1717+)
1818+1919+// generateCSRFToken returns a cryptographically random token.
2020+// 32 bytes (256 bits) base64url-encoded, matching the session token format.
2121+func generateCSRFToken() (string, error) {
2222+ b := make([]byte, 32)
2323+ if _, err := rand.Read(b); err != nil {
2424+ return "", fmt.Errorf("generate csrf token: %w", err)
2525+ }
2626+ return base64.URLEncoding.EncodeToString(b), nil
2727+}
2828+2929+// requireCSRF validates a per-session CSRF token on state-mutating requests.
3030+// Safe methods (GET/HEAD/OPTIONS) are unchecked; everything else must supply
3131+// the token via the X-CSRF-Token header (htmx path) or the csrf_token form
3232+// field on application/x-www-form-urlencoded bodies (plain-form path).
3333+// Multipart bodies are rejected unless the header is present — this avoids
3434+// consuming a multipart body in middleware and stepping on per-handler size
3535+// limits such as http.MaxBytesReader.
3636+//
3737+// Must run after requireOwner so a session is present on the request context.
3838+func (ui *AdminUI) requireCSRF(next http.Handler) http.Handler {
3939+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4040+ switch r.Method {
4141+ case http.MethodGet, http.MethodHead, http.MethodOptions:
4242+ next.ServeHTTP(w, r)
4343+ return
4444+ }
4545+4646+ session := getSessionFromContext(r.Context())
4747+ if session == nil || session.CSRFToken == "" {
4848+ slog.Warn("CSRF check failed: no session token",
4949+ "path", r.URL.Path, "method", r.Method)
5050+ csrfReject(w, r, "missing session")
5151+ return
5252+ }
5353+5454+ got := r.Header.Get(csrfHeaderName)
5555+ if got == "" {
5656+ contentType := r.Header.Get("Content-Type")
5757+ // Split off any ;boundary=... suffix before comparing.
5858+ if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
5959+ contentType = contentType[:idx]
6060+ }
6161+ contentType = strings.TrimSpace(strings.ToLower(contentType))
6262+ switch contentType {
6363+ case "application/x-www-form-urlencoded":
6464+ if err := r.ParseForm(); err == nil {
6565+ got = r.PostFormValue(csrfFormField)
6666+ }
6767+ case "multipart/form-data":
6868+ // Parse multipart with a small limit just to read the CSRF
6969+ // field. 32 KB is enough for the token + file metadata without
7070+ // loading uploaded file data into memory — Go stores the file
7171+ // portion beyond maxMemory on disk.
7272+ const csrfMultipartMaxMem = 32 << 10 // 32 KB
7373+ if err := r.ParseMultipartForm(csrfMultipartMaxMem); err == nil {
7474+ got = r.FormValue(csrfFormField)
7575+ }
7676+ }
7777+ }
7878+7979+ if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
8080+ slog.Warn("CSRF token mismatch",
8181+ "path", r.URL.Path,
8282+ "method", r.Method,
8383+ "did", session.DID,
8484+ "provided", got != "")
8585+ csrfReject(w, r, "token mismatch")
8686+ return
8787+ }
8888+ next.ServeHTTP(w, r)
8989+ })
9090+}
9191+9292+// csrfReject returns a 403. For htmx requests it surfaces a toast via the
9393+// standard HX-Trigger channel so the page-level error handler can announce
9494+// the failure; for plain browsers it's a text response.
9595+func csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
9696+ const userMsg = "Session expired or CSRF token invalid — reload the page and try again."
9797+ if r.Header.Get("HX-Request") == "true" {
9898+ w.Header().Set("HX-Trigger",
9999+ `{"toast":{"message":"`+userMsg+`","type":"error"}}`)
100100+ w.Header().Set("HX-Reswap", "none")
101101+ w.WriteHeader(http.StatusForbidden)
102102+ return
103103+ }
104104+ http.Error(w, "Forbidden: "+userMsg, http.StatusForbidden)
105105+}
106106+107107+// csrfInputHTML returns a hidden form input carrying the CSRF token, safely
108108+// escaped for attribute context.
109109+func csrfInputHTML(token string) template.HTML {
110110+ escaped := template.HTMLEscapeString(token)
111111+ return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
112112+}
+37
pkg/hold/admin/errors.go
···11+package admin
22+33+import (
44+ "encoding/json"
55+ "log/slog"
66+ "net/http"
77+)
88+99+// renderHTMXError sends an error response suitable for htmx. For htmx
1010+// requests it sets HX-Trigger so the client fires a toast; the global
1111+// htmx:responseError listener in main.js is the fallback for non-triggering
1212+// handlers. For plain browsers it degrades to http.Error. serverErr is
1313+// logged but never exposed — pass userMsg for anything user-visible.
1414+func renderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) {
1515+ if serverErr != nil {
1616+ slog.Error("admin htmx handler error",
1717+ "path", r.URL.Path,
1818+ "status", status,
1919+ "err", serverErr,
2020+ )
2121+ }
2222+ if userMsg == "" {
2323+ userMsg = http.StatusText(status)
2424+ }
2525+ if r.Header.Get("HX-Request") == "true" {
2626+ trigger := map[string]map[string]string{
2727+ "toast": {"message": userMsg, "type": "error"},
2828+ }
2929+ if b, err := json.Marshal(trigger); err == nil {
3030+ w.Header().Set("HX-Trigger", string(b))
3131+ }
3232+ w.Header().Set("HX-Reswap", "none")
3333+ w.WriteHeader(status)
3434+ return
3535+ }
3636+ http.Error(w, userMsg, status)
3737+}
+26-4
pkg/hold/admin/flash.go
···8899const flashCookieName = "hold_admin_flash"
10101111-// setFlash sets a flash message cookie
1111+// validFlashCategories bounds what can appear in alert class interpolation.
1212+// Anything outside this set is coerced to "info" before the cookie is set.
1313+var validFlashCategories = map[string]bool{
1414+ "success": true,
1515+ "error": true,
1616+ "warning": true,
1717+ "info": true,
1818+}
1919+2020+// setFlash sets a flash message cookie. Category is coerced to "info" if
2121+// not on the known allowlist — the value flows into an HTML class attribute
2222+// in layout.html and must not carry arbitrary text.
1223func setFlash(w http.ResponseWriter, r *http.Request, category, message string) {
2424+ if !validFlashCategories[category] {
2525+ category = "info"
2626+ }
1327 flash := Flash{
1428 Category: category,
1529 Message: message,
···3044 MaxAge: 60, // 1 minute - should be consumed on next page load
3145 HttpOnly: true,
3246 Secure: secure,
3333- SameSite: http.SameSiteLaxMode,
4747+ SameSite: http.SameSiteStrictMode,
3448 })
3549}
36503737-// getFlash retrieves and clears the flash message
5151+// getFlash retrieves the flash message. Callers that render the flash into
5252+// the page layout should arrange for the cookie to be cleared after display
5353+// via clearFlash — handlers typically defer clearFlash(w) at entry.
5454+// getFlash also rejects flashes whose category has drifted off the
5555+// allowlist (e.g. a forged or pre-upgrade cookie).
3856func getFlash(r *http.Request, ui *AdminUI) *Flash {
3957 cookie, err := r.Cookie(flashCookieName)
4058 if err != nil {
···5169 return nil
5270 }
53717272+ if !validFlashCategories[flash.Category] {
7373+ flash.Category = "info"
7474+ }
7575+5476 return &flash
5577}
5678···6284 Path: "/admin",
6385 MaxAge: -1,
6486 HttpOnly: true,
6565- SameSite: http.SameSiteLaxMode,
8787+ SameSite: http.SameSiteStrictMode,
6688 })
6789}