Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions gateway/internal/adminapi/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions gateway/internal/adminapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>`, 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)
Expand Down
60 changes: 54 additions & 6 deletions gateway/internal/adminapi/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -156,18 +188,23 @@ 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,
Value: id,
Path: "/_plugin",
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteStrictMode,
SameSite: sessionSameSite(r),
MaxAge: int(sessions.SessionTTL.Seconds()),
})
}
Expand All @@ -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 {
Expand Down
179 changes: 179 additions & 0 deletions gateway/internal/adminapi/tickets.go
Original file line number Diff line number Diff line change
@@ -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=<value>`.
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
}
18 changes: 17 additions & 1 deletion gateway/internal/adminapi/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' <HiveOrigin>`
// 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
Expand Down
8 changes: 8 additions & 0 deletions gateway/internal/adminapi/ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export async function apiFetch<T>(
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, {
Expand All @@ -69,6 +76,7 @@ export async function apiFetch<T>(
headers: {
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
Accept: "application/json",
"X-Bifrost-CSRF": "1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
Expand Down
Loading
Loading