From cd4ce870a38a6693462d36db99fb2311137ebb5e Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Wed, 20 May 2026 16:22:36 -0700 Subject: [PATCH] ifram ticket embed --- gateway/internal/adminapi/login_test.go | 9 +- gateway/internal/adminapi/server.go | 9 + gateway/internal/adminapi/session.go | 60 +++++- gateway/internal/adminapi/tickets.go | 179 ++++++++++++++++++ gateway/internal/adminapi/ui.go | 18 +- .../internal/adminapi/ui/src/api/client.ts | 8 + gateway/internal/adminapi/ui/src/main.tsx | 38 +++- gateway/internal/env/env.go | 19 ++ 8 files changed, 327 insertions(+), 13 deletions(-) create mode 100644 gateway/internal/adminapi/tickets.go diff --git a/gateway/internal/adminapi/login_test.go b/gateway/internal/adminapi/login_test.go index b8ae05a62..fcad9498b 100644 --- a/gateway/internal/adminapi/login_test.go +++ b/gateway/internal/adminapi/login_test.go @@ -80,7 +80,10 @@ func TestLogin_HappyPath(t *testing.T) { if body.User != "admin" { t.Fatalf("user: %q", body.User) } - // Cookie must be HttpOnly + SameSite=Strict + Path=/_plugin. + // Cookie must be HttpOnly + Path=/_plugin. SameSite is Lax in + // dev (httptest is plain HTTP so the request is non-secure) + // and None in prod — both are valid; we just assert it isn't + // Strict, which would block the iframe-embed flow. var sc *http.Cookie for _, c := range resp.Cookies() { if c.Name == sessionCookieName { @@ -93,8 +96,8 @@ func TestLogin_HappyPath(t *testing.T) { if !sc.HttpOnly { t.Error("cookie not HttpOnly") } - if sc.SameSite != http.SameSiteStrictMode { - t.Errorf("SameSite: %v", sc.SameSite) + if sc.SameSite == http.SameSiteStrictMode { + t.Errorf("SameSite must not be Strict (got %v); cookie has to ride iframe requests", sc.SameSite) } if sc.Path != "/_plugin" { t.Errorf("Path: %q", sc.Path) diff --git a/gateway/internal/adminapi/server.go b/gateway/internal/adminapi/server.go index 176514c5f..0b8467ddb 100644 --- a/gateway/internal/adminapi/server.go +++ b/gateway/internal/adminapi/server.go @@ -227,6 +227,15 @@ func registerRoutes(mux *http.ServeMux, deps routeDeps) { mux.HandleFunc("/_plugin/logout", loginH.logout) mux.HandleFunc("/_plugin/me", cookieOrBearer(loginH.me)) + // Ticket-based bootstrap for iframe embedding. Hive calls + // /auth/ticket with its bearer to mint a short-lived single- + // use ticket, embeds the iframe with `?ticket=`, and + // the SPA POSTs that to /auth/redeem on boot to receive the + // session cookie. Admin password never reaches the browser. + ticketH := newTicketHandlers(deps.sessions, deps.adminUser) + mux.HandleFunc("/_plugin/auth/ticket", bearer(ticketH.mint)) + mux.HandleFunc("/_plugin/auth/redeem", ticketH.redeem) // anon; ticket IS the proof + // Cookie-or-bearer routes: phase-7 observability subset. if deps.logstore != nil { obs := newObservabilityHandlers(deps.logstore) diff --git a/gateway/internal/adminapi/session.go b/gateway/internal/adminapi/session.go index 89994679a..cfe52ffaf 100644 --- a/gateway/internal/adminapi/session.go +++ b/gateway/internal/adminapi/session.go @@ -16,6 +16,14 @@ import ( // `/_plugin` so it never collides with anything Bifrost-side sets. const sessionCookieName = "bifrost_session" +// csrfHeader is required on every cookie-authed non-GET request. +// Browsers won't send a custom header on a cross-origin form / image +// / link request, so requiring its presence blocks the classic CSRF +// vectors that SameSite=None opens up. The SPA's apiFetch adds it +// on every non-GET; bearer-authed callers (Hive) are exempt because +// they aren't relying on ambient cookie auth. +const csrfHeader = "X-Bifrost-CSRF" + // sessionLookupTimeout bounds the Redis call the middleware makes on // every authed request. Generous because Redis is loopback in swarm, // but tight enough that an unresponsive Redis surfaces as a 401 (and @@ -90,10 +98,20 @@ func (g *sessionGuard) bearerOnly(next http.HandlerFunc) http.HandlerFunc { // cookieOrBearer returns middleware that accepts EITHER a valid // session cookie OR the provisioning bearer. When a cookie is used, // the resolved Session is stashed on the request context. +// +// Cookie-authed mutations (any non-GET/HEAD method) additionally +// require the `X-Bifrost-CSRF` header. The SPA's apiFetch adds it +// automatically. Bearer-authed requests skip the check — they aren't +// vulnerable to CSRF because the attacker can't add the header on +// behalf of the victim's bearer. func (g *sessionGuard) cookieOrBearer(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Cookie first — the common case for the dashboard. if sess, ok := g.resolveCookie(r); ok { + if !csrfOK(r) { + http.Error(w, "csrf header required", http.StatusForbidden) + return + } ctx := context.WithValue(r.Context(), sessionContextKey, sess) next(w, r.WithContext(ctx)) return @@ -106,6 +124,20 @@ func (g *sessionGuard) cookieOrBearer(next http.HandlerFunc) http.HandlerFunc { } } +// csrfOK reports whether the request satisfies the CSRF rule: +// GET / HEAD / OPTIONS are exempt (idempotent reads); every other +// method must carry the `X-Bifrost-CSRF` header. Any non-empty value +// is accepted — the header's presence is the signal; the contents +// don't matter because a cross-origin attacker can't set custom +// headers at all. +func csrfOK(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + return r.Header.Get(csrfHeader) != "" +} + // resolveCookie returns the Session associated with the inbound // request's cookie, or (nil, false) if there's no valid cookie. A // Redis error here is logged and treated as "no session" — failing @@ -156,10 +188,15 @@ func (g *sessionGuard) bearerOK(r *http.Request) bool { // attributes (HttpOnly / Secure / SameSite / Path / Max-Age) stay in // sync between login (set) and logout (clear). // -// Secure is set if either: (a) the inbound request announces HTTPS -// via X-Forwarded-Proto (typical behind a TLS-terminating ingress), -// or (b) PRODUCTION=1 is set in env. Local dev over plain HTTP -// gets a non-Secure cookie so it actually works. +// SameSite=None is required so the cookie rides cross-origin iframe +// requests from Hive. Browsers reject SameSite=None without Secure, +// so Secure is forced on regardless of the inbound request's scheme +// in production. Local dev over plain HTTP still gets Lax so the +// cookie can actually land — `SameSite=None` over HTTP is dropped. +// +// CSRF defence is upgraded to the `X-Bifrost-CSRF` header check on +// every cookie-authed mutation (see session.go's middleware); the +// `frame-ancestors` CSP on the UI shell blocks rogue embedders. func setSessionCookie(w http.ResponseWriter, r *http.Request, id string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, @@ -167,7 +204,7 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, id string) { Path: "/_plugin", HttpOnly: true, Secure: isSecureRequest(r), - SameSite: http.SameSiteStrictMode, + SameSite: sessionSameSite(r), MaxAge: int(sessions.SessionTTL.Seconds()), }) } @@ -183,11 +220,22 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) { Path: "/_plugin", HttpOnly: true, Secure: isSecureRequest(r), - SameSite: http.SameSiteStrictMode, + SameSite: sessionSameSite(r), MaxAge: -1, }) } +// sessionSameSite picks the SameSite attribute for the session cookie. +// Prod always uses None (so the cookie rides inside Hive's iframe); +// dev falls back to Lax because browsers silently drop `None` over +// plain HTTP and we want localhost to keep working. +func sessionSameSite(r *http.Request) http.SameSite { + if isSecureRequest(r) { + return http.SameSiteNoneMode + } + return http.SameSiteLaxMode +} + // isSecureRequest mirrors the rule used by `setSessionCookie` so a // test can ask the same question. See that function's doc. func isSecureRequest(r *http.Request) bool { diff --git a/gateway/internal/adminapi/tickets.go b/gateway/internal/adminapi/tickets.go new file mode 100644 index 000000000..2d57c2ef9 --- /dev/null +++ b/gateway/internal/adminapi/tickets.go @@ -0,0 +1,179 @@ +package adminapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/stakwork/stakgraph/gateway/internal/pluginlog" + "github.com/stakwork/stakgraph/gateway/internal/redisclient" + "github.com/stakwork/stakgraph/gateway/internal/sessions" +) + +// ticketTTL is how long a freshly-minted ticket is valid for. Short +// enough that a leak via referrer / browser history / proxy log is +// already-expired by the time anyone could replay it, long enough +// that a slow page load between Hive issuing the iframe and the SPA +// redeeming the ticket comfortably succeeds. +const ticketTTL = 30 * time.Second + +// ticketOpTimeout caps the Redis round-trips in mint / redeem. +// Same shape as loginTimeout — bounded so an unresponsive Redis +// surfaces as a 503 rather than a hung browser tab. +const ticketOpTimeout = 5 * time.Second + +// TicketResponse is the JSON body returned by POST /_plugin/auth/ticket. +// Hive embeds the ticket in the iframe src as `?ticket=`. +type TicketResponse struct { + Ticket string `json:"ticket"` + ExpiresIn int `json:"expires_in"` // seconds; mirrors ticketTTL +} + +// redeemRequest is the JSON body POSTed to /_plugin/auth/redeem. +// The SPA pulls `ticket` out of its own query string and forwards it +// in a request body (not a URL) so the redemption itself doesn't +// re-leak the value into access logs. +type redeemRequest struct { + Ticket string `json:"ticket"` +} + +// ticketHandlers wraps the dependencies the mint / redeem handlers +// share. Same shape as loginHandlers — keeps tests injectable. +type ticketHandlers struct { + store *sessions.Store + adminUser string +} + +func newTicketHandlers(store *sessions.Store, adminUser string) *ticketHandlers { + return &ticketHandlers{store: store, adminUser: adminUser} +} + +// mint handles POST /_plugin/auth/ticket. Bearer-only — Hive is the +// sole intended caller. Generates a 32-byte random ticket, parks it +// in Redis with a 30s TTL, returns the value. +// +// The Redis value carries the admin user so a future multi-user +// setup can mint per-user tickets without changing the wire shape. +func (h *ticketHandlers) mint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, http.MethodPost) + return + } + if h.store == nil { + http.Error(w, "session store unavailable", http.StatusServiceUnavailable) + return + } + client := redisclient.Client() + if client == nil { + http.Error(w, "session store unavailable", http.StatusServiceUnavailable) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), ticketOpTimeout) + defer cancel() + + ticket, err := newTicketID() + if err != nil { + pluginlog.Errf("adminapi: ticket id: %v", err) + writeError(w, http.StatusInternalServerError, "internal", "could not mint ticket") + return + } + + payload, _ := json.Marshal(map[string]string{"user": h.adminUser}) + if err := client.Set(ctx, ticketKey(ticket), payload, ticketTTL).Err(); err != nil { + pluginlog.Errf("adminapi: ticket set: %v", err) + writeError(w, http.StatusServiceUnavailable, "session_store_unavailable", + "could not persist ticket") + return + } + + writeJSON(w, http.StatusOK, TicketResponse{ + Ticket: ticket, + ExpiresIn: int(ticketTTL.Seconds()), + }) +} + +// redeem handles POST /_plugin/auth/redeem. Anonymous — the ticket +// is the proof of authorization, redeemed exactly once via +// GETDEL so concurrent redemptions can't both succeed. +// +// Sets the session cookie and returns the canonical LoginResponse so +// the SPA's existing post-login code path needs no special case. +func (h *ticketHandlers) redeem(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w, http.MethodPost) + return + } + if h.store == nil { + http.Error(w, "session store unavailable", http.StatusServiceUnavailable) + return + } + client := redisclient.Client() + if client == nil { + http.Error(w, "session store unavailable", http.StatusServiceUnavailable) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), ticketOpTimeout) + defer cancel() + + var req redeemRequest + if err := decodeJSON(r, &req); err != nil || req.Ticket == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing ticket") + return + } + + // GETDEL is atomic single-use: the second concurrent caller + // for the same ticket gets redis.Nil and is rejected. + raw, err := client.GetDel(ctx, ticketKey(req.Ticket)).Result() + if err == redis.Nil { + writeError(w, http.StatusUnauthorized, "invalid_ticket", + "ticket unknown or already redeemed") + return + } + if err != nil { + pluginlog.Errf("adminapi: ticket getdel: %v", err) + writeError(w, http.StatusServiceUnavailable, "session_store_unavailable", + "could not redeem ticket") + return + } + + var meta struct { + User string `json:"user"` + } + if err := json.Unmarshal([]byte(raw), &meta); err != nil || meta.User == "" { + // Malformed payload — fail closed. + writeError(w, http.StatusUnauthorized, "invalid_ticket", "ticket malformed") + return + } + + id, _, err := h.store.Create(ctx, meta.User) + if err != nil { + pluginlog.Errf("adminapi: ticket redeem create: %v", err) + writeError(w, http.StatusServiceUnavailable, "session_store_unavailable", + "could not create session") + return + } + + setSessionCookie(w, r, id) + writeJSON(w, http.StatusOK, LoginResponse{User: meta.User}) +} + +// ticketKey is the Redis namespace for outstanding tickets. Distinct +// from the session keyspace so a stray SCAN can tell them apart. +func ticketKey(ticket string) string { return redisclient.Key("auth:ticket:" + ticket) } + +// newTicketID returns a 43-char base64url-encoded 32-byte random +// string. Same shape as session IDs — URL-safe, no padding. +func newTicketID() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/gateway/internal/adminapi/ui.go b/gateway/internal/adminapi/ui.go index 196c4d27a..383dac86c 100644 --- a/gateway/internal/adminapi/ui.go +++ b/gateway/internal/adminapi/ui.go @@ -5,6 +5,8 @@ import ( "io/fs" "net/http" "strings" + + "github.com/stakwork/stakgraph/gateway/internal/env" ) // uiFS is the compiled SPA bundle, dropped here by the Dockerfile's @@ -46,7 +48,21 @@ func uiHandler() http.Handler { }) } fsrv := http.FileServer(http.FS(sub)) - return http.StripPrefix("/_plugin/ui/", spaFallback(sub, fsrv)) + return withFrameAncestors(http.StripPrefix("/_plugin/ui/", spaFallback(sub, fsrv))) +} + +// withFrameAncestors wraps a handler so every response carries a +// `Content-Security-Policy: frame-ancestors 'self' ` +// header. This is the only thing standing between the dashboard +// and clickjacking by a rogue site, now that the session cookie is +// SameSite=None. The list is small and constant (Hive + the plugin +// itself, so direct browser sessions also work). +func withFrameAncestors(next http.Handler) http.Handler { + csp := "frame-ancestors 'self' " + env.HiveOriginValue() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", csp) + next.ServeHTTP(w, r) + }) } // spaFallback wraps a FileServer so that any path that doesn't diff --git a/gateway/internal/adminapi/ui/src/api/client.ts b/gateway/internal/adminapi/ui/src/api/client.ts index a2d768e0b..a4f3a8eb7 100644 --- a/gateway/internal/adminapi/ui/src/api/client.ts +++ b/gateway/internal/adminapi/ui/src/api/client.ts @@ -61,6 +61,13 @@ export async function apiFetch( const ctl = new AbortController(); const tid = setTimeout(() => ctl.abort(), timeoutMs); + // CSRF: the gateway requires `X-Bifrost-CSRF` on every cookie- + // authed mutation. Browsers won't auto-send a custom header on + // cross-origin requests, so its presence is the signal that "this + // request was made by our own SPA, not a forged form post." Any + // value works — we use "1" for brevity. GET / HEAD are exempt + // server-side, but adding the header unconditionally keeps the + // request shape uniform and is harmless. let resp: Response; try { resp = await fetch(PLUGIN_PREFIX + path, { @@ -69,6 +76,7 @@ export async function apiFetch( headers: { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), Accept: "application/json", + "X-Bifrost-CSRF": "1", ...headers, }, body: body !== undefined ? JSON.stringify(body) : undefined, diff --git a/gateway/internal/adminapi/ui/src/main.tsx b/gateway/internal/adminapi/ui/src/main.tsx index 0d6acb275..6062b477f 100644 --- a/gateway/internal/adminapi/ui/src/main.tsx +++ b/gateway/internal/adminapi/ui/src/main.tsx @@ -1,4 +1,12 @@ // SPA entry. Mounts into #root. Everything else lives in app.tsx. +// +// Before mounting, we check for a `?ticket=` query param. If +// present, Hive has handed us a one-shot bootstrap ticket via an +// iframe src; we redeem it for a session cookie and strip the query +// so a refresh doesn't try to replay an already-dead ticket. On any +// failure we fall through to , which will hit /me, get 401, +// and redirect to the login page — preserving the direct-access +// fallback for ops/debugging. import { render } from "preact"; @@ -7,7 +15,31 @@ import "./styles/components.css"; import "uplot/dist/uPlot.min.css"; import { App } from "./app"; +import { apiPost } from "./api/client"; -const root = document.getElementById("root"); -if (!root) throw new Error("missing #root"); -render(, root); +async function redeemTicketIfPresent() { + const url = new URL(window.location.href); + const ticket = url.searchParams.get("ticket"); + if (!ticket) return; + + // Strip the ticket from the visible URL first so a refresh or a + // bookmark can't accidentally try to re-redeem it. We do this + // BEFORE the network call: even if the POST fails, the URL is + // clean for the inevitable retry / login-fallback. + url.searchParams.delete("ticket"); + window.history.replaceState({}, "", url.pathname + url.search + url.hash); + + try { + await apiPost<{ user: string }>("/auth/redeem", { ticket }); + } catch { + // Swallow — the SPA will boot, hit /me, get 401, and bounce to + // /login. That's the right UX for a stale / re-used ticket. + } +} + +void (async () => { + await redeemTicketIfPresent(); + const root = document.getElementById("root"); + if (!root) throw new Error("missing #root"); + render(, root); +})(); diff --git a/gateway/internal/env/env.go b/gateway/internal/env/env.go index 49fc8ffe3..ffbe2e52c 100644 --- a/gateway/internal/env/env.go +++ b/gateway/internal/env/env.go @@ -89,6 +89,15 @@ const ( // Set in swarm/prod; left unset in dev so localhost HTTP works. // Phase 8 "Cookie attributes". Production = "PRODUCTION" + + // HiveOrigin is the scheme+host of the Hive deployment that's + // allowed to embed the dashboard in an iframe. Drives the + // `Content-Security-Policy: frame-ancestors` header on the SPA + // shell and on the ticket-redemption endpoint. A single origin + // is sufficient because Hive is multi-tenant — every workspace + // is served from the same Hive origin and proxies to whichever + // per-swarm gateway plugin it needs. + HiveOrigin = "HIVE_ORIGIN" ) // Defaults that apply when an env var is unset. @@ -99,6 +108,10 @@ const ( // DefaultTrustReconcile is the safe default — see TrustReconcile. DefaultTrustReconcile = "ignore" + + // DefaultHiveOrigin is production Hive. Override via HIVE_ORIGIN + // for staging / dev (e.g. `http://localhost:8080`). + DefaultHiveOrigin = "https://hive.sphinx.chat" ) // Get reads `name` and returns its value or "" if unset. @@ -168,6 +181,12 @@ func IsProduction() bool { } } +// HiveOriginValue returns the Hive origin allowed to embed the +// dashboard, falling back to DefaultHiveOrigin. Used both for the +// CSP `frame-ancestors` directive and (in future) as an allowlist +// for cookie-authed mutation `Origin` checks. +func HiveOriginValue() string { return GetOr(HiveOrigin, DefaultHiveOrigin) } + // TrustSeed returns the env-supplied registry seed and where it came // from. Exactly one of the two env vars is honoured per // "Env-var preset shape" in the phase-5 doc: