diff --git a/desktop/src/lib/auth-guard.test.ts b/desktop/src/lib/auth-guard.test.ts index e148adc51..568cb9d0e 100644 --- a/desktop/src/lib/auth-guard.test.ts +++ b/desktop/src/lib/auth-guard.test.ts @@ -37,10 +37,32 @@ describe("installAuthGuard CSRF wiring", () => { const lookalike = spy.mock.calls.at(-1)?.[1] as RequestInit; expect(new Headers(lookalike.headers).get("X-CSRF-Token")).toBeNull(); - // Request-object input for a same-origin mutating call: the wrapper rebuilds - // the Request with the header attached (first arg is the Request). + // Request-object input for a same-origin mutating call: the wrapper merges + // the CSRF token into effectiveInit so native fetch respects any + // init-provided method/headers and preserves the original body stream. await window.fetch(new Request(`${window.location.origin}/api/x`, { method: "POST" })); - const reqArg = spy.mock.calls.at(-1)?.[0] as Request; - expect(reqArg.headers.get("X-CSRF-Token")).toBe("tok123"); + const reqInit = spy.mock.calls.at(-1)?.[1] as RequestInit; + expect(new Headers(reqInit.headers).get("X-CSRF-Token")).toBe("tok123"); + + // Request headers AND init headers together: `init?.headers || input.headers` + // discarded the Request's own headers whenever init carried any, so an + // Authorization set on the Request went missing on every mutating call that + // also passed init.headers. Both sources must survive, init winning ties. + // (These assertions live in this block deliberately: installAuthGuard has a + // module-level `installed` guard, so a second install in a new it() is a + // no-op and window.fetch would be the bare spy.) + await window.fetch( + new Request(`${window.location.origin}/api/y`, { + method: "POST", + headers: { Authorization: "Bearer abc", "X-From-Request": "1" }, + }), + { headers: { "X-From-Init": "2" } }, + ); + const merged = new Headers((spy.mock.calls.at(-1)?.[1] as RequestInit).headers); + expect(merged.get("Authorization")).toBe("Bearer abc"); + expect(merged.get("X-From-Request")).toBe("1"); + expect(merged.get("X-From-Init")).toBe("2"); + expect(merged.get("X-CSRF-Token")).toBe("tok123"); }); + }); diff --git a/desktop/src/lib/auth-guard.ts b/desktop/src/lib/auth-guard.ts index 7cd121ac9..c1f59cd52 100644 --- a/desktop/src/lib/auth-guard.ts +++ b/desktop/src/lib/auth-guard.ts @@ -65,7 +65,7 @@ export function installAuthGuard(): void { // the backend's router-wide verify_csrf gate is satisfied at every call // site, not only the handful that wrap withCsrf() by hand. Covers both // string/URL inputs (via withCsrf on init) and Request-object inputs (by - // rebuilding the Request with the header). Gated to same-origin so the token + // merging the CSRF token into effectiveInit). Gated to same-origin so the token // never leaks off-site; never overwrites an X-CSRF-Token a caller already set. let effectiveInput: RequestInfo | URL = input; let effectiveInit = init; @@ -73,21 +73,28 @@ export function installAuthGuard(): void { if (isSameOrigin(input.toString())) effectiveInit = withCsrf(init); } else if (typeof Request !== "undefined" && input instanceof Request) { try { - const method = (input.method || "GET").toUpperCase(); - if ( - CSRF_MUTATING.has(method) && - isSameOrigin(input.url) && - !input.headers.has("X-CSRF-Token") - ) { + const method = (init?.method || input.method || "GET").toUpperCase(); + if (CSRF_MUTATING.has(method) && isSameOrigin(input.url)) { const token = getCsrfToken(); if (token) { + // Merge BOTH sources rather than picking one. `init?.headers || + // input.headers` silently discarded the Request's own headers + // whenever init also carried headers, so + // fetch(new Request(url, {headers: {Authorization}}), {method, headers}) + // lost the Authorization header entirely. init wins on conflict, + // matching how fetch itself resolves the two. const headers = new Headers(input.headers); - headers.set("X-CSRF-Token", token); - effectiveInput = new Request(input, { headers }); + if (init?.headers) { + new Headers(init.headers).forEach((v, k) => headers.set(k, v)); + } + if (!headers.has("X-CSRF-Token")) { + headers.set("X-CSRF-Token", token); + effectiveInit = { ...init, headers }; + } } } } catch { - effectiveInput = input; + // Fallback to original input/init on error } } const response = await originalFetch(effectiveInput, effectiveInit);