diff --git a/apps/api/internal/httpapi/csrf_test.go b/apps/api/internal/httpapi/csrf_test.go index d83cefd..2f6c3ce 100644 --- a/apps/api/internal/httpapi/csrf_test.go +++ b/apps/api/internal/httpapi/csrf_test.go @@ -62,6 +62,14 @@ func TestCSRFAllowsSafeMethodsWithoutHeader(t *testing.T) { if !found { t.Fatal("expected non-HttpOnly CSRF cookie on first GET") } + hdr := rec.Header().Get("X-CSRF-Token") + if hdr == "" { + t.Fatal("expected X-CSRF-Token response header on GET (cross-origin SPA seed)") + } + cookie := findCSRFCookie(rec.Result().Cookies()) + if cookie == nil || cookie.Value != hdr { + t.Fatalf("X-CSRF-Token header %q must match cookie value", hdr) + } } func TestCSRFRejectsPOSTWithoutToken(t *testing.T) { diff --git a/apps/api/internal/httpapi/middleware.go b/apps/api/internal/httpapi/middleware.go index 98509a8..dee96cd 100644 --- a/apps/api/internal/httpapi/middleware.go +++ b/apps/api/internal/httpapi/middleware.go @@ -232,6 +232,9 @@ func (s *Server) CSRF(next http.Handler) http.Handler { MaxAge: 7 * 24 * 60 * 60, }) } + // Expose for cross-origin SPAs (api.* vs app host): document.cookie cannot + // read host-only API cookies; the client seeds via GET and mirrors this header. + w.Header().Set("X-CSRF-Token", token) if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { next.ServeHTTP(w, r) diff --git a/apps/api/internal/httpapi/security_middleware_test.go b/apps/api/internal/httpapi/security_middleware_test.go index d5f3b2b..2be8733 100644 --- a/apps/api/internal/httpapi/security_middleware_test.go +++ b/apps/api/internal/httpapi/security_middleware_test.go @@ -137,6 +137,18 @@ func TestCORSAllowsConfiguredOriginOnly(t *testing.T) { t.Fatalf("allow credentials = %q", got) } + get := httptest.NewRecorder() + reqGet := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + reqGet.Header.Set("Origin", "http://localhost:5174") + h.ServeHTTP(get, reqGet) + exposed := get.Header().Get("Access-Control-Expose-Headers") + if !strings.Contains(strings.ToLower(exposed), "x-csrf-token") { + t.Fatalf("expose headers = %q, want X-CSRF-Token", exposed) + } + if tok := get.Header().Get("X-CSRF-Token"); tok == "" { + t.Fatal("expected X-CSRF-Token on credentialed GET (SPA cross-origin seed)") + } + twin := httptest.NewRecorder() reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) reqTwin.Header.Set("Origin", "http://127.0.0.1:5174") diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 09e63f9..e8f3f58 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -263,6 +263,7 @@ func (s *Server) Router() http.Handler { AllowedOrigins: config.CORSAllowedOrigins(s.Config.WebOrigin), AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Accept-Language", "Authorization", "Content-Type", "X-API-Key", "X-CSRF-Token", "X-Company-ID"}, + ExposedHeaders: []string{"X-CSRF-Token", "X-Products-Exported"}, AllowCredentials: true, MaxAge: 300, })) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 273e269..39c3f39 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -137,37 +137,73 @@ function csrfCookieSecure(): boolean { /** Dedup concurrent seed GETs (login submit + parallel mutations). */ let csrfSeedInflight: Promise | null = null; +/** Cross-origin: API host-only cookies are invisible to document.cookie — cache header. */ +let csrfTokenMemory: string | null = null; + +function isCrossOriginApi(apiBase: string): boolean { + if (!apiBase || typeof location === "undefined") return false; + try { + return new URL(apiBase, location.href).origin !== location.origin; + } catch { + return false; + } +} /** * Double-submit CSRF: cookie value must equal X-CSRF-Token on mutating calls. - * Proven pattern (browser + curl): GET /api/auth/me seeds descrybe_csrf (401 ok when - * logged out), then POST with X-CSRF-Token matching that cookie. Prefer API-issued - * cookie over local mint so the jar matches what credentialed fetch sends. + * Seed with GET /api/auth/me (401 ok when logged out). Prefer the API's + * X-CSRF-Token response header so cross-origin SPAs (app host → api.*) work — + * document.cookie cannot read the API host-only cookie. Same-origin may still + * mint locally when the seed header is unavailable. */ async function ensureCsrfCookie(apiBase: string): Promise { - let token = readCookie(CSRF_COOKIE_NAME); - if (token) return token; + if (csrfTokenMemory) return csrfTokenMemory; + + const crossOrigin = isCrossOriginApi(apiBase); + // Same-origin only: a cookie on the page host is the API cookie. + // Cross-origin: page-host cookies are the wrong jar and cause mismatches. + if (!crossOrigin) { + const token = readCookie(CSRF_COOKIE_NAME); + if (token) { + csrfTokenMemory = token; + return token; + } + } if (typeof document === "undefined") return null; if (!csrfSeedInflight) { csrfSeedInflight = (async () => { + let fromHeader: string | null = null; try { const seedPath = "/api/auth/me"; const seedUrl = apiBase ? `${apiBase}${seedPath}` : seedPath; - await fetch(seedUrl, { + const res = await fetch(seedUrl, { method: "GET", credentials: "include", headers: { Accept: "application/json" } }); + const header = res.headers.get("X-CSRF-Token"); + if (header && header.trim()) fromHeader = header.trim(); } catch { - /* network — fall through to mint */ + /* network — fall through */ + } + if (fromHeader) { + csrfTokenMemory = fromHeader; + return fromHeader; + } + if (crossOrigin) { + // Do not mint on the page host — that cookie is never sent to api.*. + return null; } const seeded = readCookie(CSRF_COOKIE_NAME); - if (seeded) return seeded; - // Same-host mint fallback (loopback twin already aligned via apiBase()). + if (seeded) { + csrfTokenMemory = seeded; + return seeded; + } const minted = mintCsrfToken(); const secure = csrfCookieSecure() ? "; Secure" : ""; document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(minted)}; Path=/; SameSite=Lax; Max-Age=${CSRF_MAX_AGE_SEC}${secure}`; + csrfTokenMemory = minted; return minted; })().finally(() => { csrfSeedInflight = null;