This commit is contained in:
2026-08-13 21:01:49 +02:00
parent 593edf35fe
commit 58578fd010
5 changed files with 69 additions and 9 deletions
+45 -9
View File
@@ -137,37 +137,73 @@ function csrfCookieSecure(): boolean {
/** Dedup concurrent seed GETs (login submit + parallel mutations). */
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.
* 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<string | null> {
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;