From 3a4505fb676b5a083ae44b5ee7b083be8fc013f8 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:32:20 +0200 Subject: [PATCH] fix(csrf): merge CSRF token into effectiveInit for Request inputs, not rebuild - Compute effective method from init?.method || input.method - Merge token into effectiveInit headers instead of rebuilding Request (avoids body stream consumption and respects init-provided headers/method) - Update test to inspect init.headers instead of Request.headers Addresses Kilo finding (Request inputs excluded from CSRF) and CodeRabbit finding (broken token injection when init overrides method/headers + body stream consumption). Refs: PR #1991 --- desktop/src/lib/auth-guard.test.ts | 9 +++++---- desktop/src/lib/auth-guard.ts | 21 ++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/desktop/src/lib/auth-guard.test.ts b/desktop/src/lib/auth-guard.test.ts index e148adc51..d91502f15 100644 --- a/desktop/src/lib/auth-guard.test.ts +++ b/desktop/src/lib/auth-guard.test.ts @@ -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"); }); }); diff --git a/desktop/src/lib/auth-guard.ts b/desktop/src/lib/auth-guard.ts index 7cd121ac9..1a2364b52 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,20 @@ 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) { - const headers = new Headers(input.headers); - headers.set("X-CSRF-Token", token); - effectiveInput = new Request(input, { headers }); + const baseHeaders = init?.headers || input.headers; + 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);