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
30 changes: 26 additions & 4 deletions desktop/src/lib/auth-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

});
27 changes: 17 additions & 10 deletions desktop/src/lib/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,29 +65,36 @@ 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;
if (typeof input === "string" || input instanceof URL) {
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);
Expand Down
Loading