Skip to content
Open
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
9 changes: 5 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,11 @@ 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");
});
});
21 changes: 10 additions & 11 deletions desktop/src/lib/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,29 +65,28 @@ 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) {
const headers = new Headers(input.headers);
headers.set("X-CSRF-Token", token);
effectiveInput = new Request(input, { headers });
const baseHeaders = init?.headers || input.headers;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Headers set on the input Request object are silently dropped when init.headers is also provided.

baseHeaders is init?.headers || input.headers, so if the caller passes headers via init (e.g. fetch(new Request(url, { headers: { Authorization } }), { method: 'POST', headers: { ... } })), the Request object's own headers are discarded entirely. The previous new Request(input, { headers }) approach preserved input's headers. Consider merging both sources, e.g. new Headers(input.headers) then overlay init?.headers, before deciding whether to set the token.

Suggested change
const baseHeaders = init?.headers || input.headers;
const baseHeaders = new Headers(input.headers);
if (init?.headers) new Headers(init.headers).forEach((v, k) => baseHeaders.set(k, v));

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const headers = new Headers(baseHeaders);
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