fix
This commit is contained in:
@@ -62,6 +62,14 @@ func TestCSRFAllowsSafeMethodsWithoutHeader(t *testing.T) {
|
|||||||
if !found {
|
if !found {
|
||||||
t.Fatal("expected non-HttpOnly CSRF cookie on first GET")
|
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) {
|
func TestCSRFRejectsPOSTWithoutToken(t *testing.T) {
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ func (s *Server) CSRF(next http.Handler) http.Handler {
|
|||||||
MaxAge: 7 * 24 * 60 * 60,
|
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 {
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ func TestCORSAllowsConfiguredOriginOnly(t *testing.T) {
|
|||||||
t.Fatalf("allow credentials = %q", got)
|
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()
|
twin := httptest.NewRecorder()
|
||||||
reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||||
reqTwin.Header.Set("Origin", "http://127.0.0.1:5174")
|
reqTwin.Header.Set("Origin", "http://127.0.0.1:5174")
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
AllowedOrigins: config.CORSAllowedOrigins(s.Config.WebOrigin),
|
AllowedOrigins: config.CORSAllowedOrigins(s.Config.WebOrigin),
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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"},
|
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,
|
AllowCredentials: true,
|
||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+45
-9
@@ -137,37 +137,73 @@ function csrfCookieSecure(): boolean {
|
|||||||
|
|
||||||
/** Dedup concurrent seed GETs (login submit + parallel mutations). */
|
/** Dedup concurrent seed GETs (login submit + parallel mutations). */
|
||||||
let csrfSeedInflight: Promise<string | null> | null = null;
|
let csrfSeedInflight: Promise<string | null> | 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.
|
* 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
|
* Seed with GET /api/auth/me (401 ok when logged out). Prefer the API's
|
||||||
* logged out), then POST with X-CSRF-Token matching that cookie. Prefer API-issued
|
* X-CSRF-Token response header so cross-origin SPAs (app host → api.*) work —
|
||||||
* cookie over local mint so the jar matches what credentialed fetch sends.
|
* 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<string | null> {
|
async function ensureCsrfCookie(apiBase: string): Promise<string | null> {
|
||||||
let token = readCookie(CSRF_COOKIE_NAME);
|
if (csrfTokenMemory) return csrfTokenMemory;
|
||||||
if (token) return token;
|
|
||||||
|
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 (typeof document === "undefined") return null;
|
||||||
|
|
||||||
if (!csrfSeedInflight) {
|
if (!csrfSeedInflight) {
|
||||||
csrfSeedInflight = (async () => {
|
csrfSeedInflight = (async () => {
|
||||||
|
let fromHeader: string | null = null;
|
||||||
try {
|
try {
|
||||||
const seedPath = "/api/auth/me";
|
const seedPath = "/api/auth/me";
|
||||||
const seedUrl = apiBase ? `${apiBase}${seedPath}` : seedPath;
|
const seedUrl = apiBase ? `${apiBase}${seedPath}` : seedPath;
|
||||||
await fetch(seedUrl, {
|
const res = await fetch(seedUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { Accept: "application/json" }
|
headers: { Accept: "application/json" }
|
||||||
});
|
});
|
||||||
|
const header = res.headers.get("X-CSRF-Token");
|
||||||
|
if (header && header.trim()) fromHeader = header.trim();
|
||||||
} catch {
|
} 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);
|
const seeded = readCookie(CSRF_COOKIE_NAME);
|
||||||
if (seeded) return seeded;
|
if (seeded) {
|
||||||
// Same-host mint fallback (loopback twin already aligned via apiBase()).
|
csrfTokenMemory = seeded;
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
const minted = mintCsrfToken();
|
const minted = mintCsrfToken();
|
||||||
const secure = csrfCookieSecure() ? "; Secure" : "";
|
const secure = csrfCookieSecure() ? "; Secure" : "";
|
||||||
document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(minted)}; Path=/; SameSite=Lax; Max-Age=${CSRF_MAX_AGE_SEC}${secure}`;
|
document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(minted)}; Path=/; SameSite=Lax; Max-Age=${CSRF_MAX_AGE_SEC}${secure}`;
|
||||||
|
csrfTokenMemory = minted;
|
||||||
return minted;
|
return minted;
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
csrfSeedInflight = null;
|
csrfSeedInflight = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user