diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts index d4016180ee..b6fa567254 100644 --- a/frontend/src/core/api/fetcher.ts +++ b/frontend/src/core/api/fetcher.ts @@ -39,7 +39,16 @@ export function readCsrfCookie(): string | null { if (typeof document === "undefined") return null; for (const pair of document.cookie.split("; ")) { if (pair.startsWith(CSRF_COOKIE_PREFIX)) { - return decodeURIComponent(pair.slice(CSRF_COOKIE_PREFIX.length)); + const value = pair.slice(CSRF_COOKIE_PREFIX.length); + try { + return decodeURIComponent(value); + } catch { + // Cookies are shared by every app on the same host. A value set or + // truncated outside the gateway (or a stray `%` escape) is still a + // string, and throwing here would break every API call — fall back + // to the raw value, as `getLocaleFromCookie` does. + return value; + } } } return null; diff --git a/frontend/tests/unit/core/api/fetcher.dom.test.ts b/frontend/tests/unit/core/api/fetcher.dom.test.ts index ddd3bd07b4..c8371200cb 100644 --- a/frontend/tests/unit/core/api/fetcher.dom.test.ts +++ b/frontend/tests/unit/core/api/fetcher.dom.test.ts @@ -58,3 +58,32 @@ describe("api fetcher unauthorized redirect", () => { expect(window.location.href).not.toContain("next="); }); }); + +describe("api fetcher CSRF cookies", () => { + afterEach(() => { + document.cookie = "theme=; max-age=0; path=/"; + document.cookie = "csrf_token=; max-age=0; path=/"; + rs.unstubAllGlobals(); + }); + + it("forwards a malformed CSRF cookie after an unrelated malformed cookie", async () => { + const sentHeaders: Headers[] = []; + rs.stubGlobal( + "fetch", + rs.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + sentHeaders.push(new Headers(init?.headers)); + return new Response("ok"); + }), + ); + // Each assignment sets one cookie; semicolon-separated tails are attributes. + document.cookie = "theme=%E0; path=/"; + document.cookie = "csrf_token=%gh; path=/"; + + expect(document.cookie).toBe("theme=%E0; csrf_token=%gh"); + + await apiFetch("http://localhost/api/threads", { method: "POST" }); + + expect(sentHeaders).toHaveLength(1); + expect(sentHeaders[0]?.get("X-CSRF-Token")).toBe("%gh"); + }); +}); diff --git a/frontend/tests/unit/core/api/fetcher.test.ts b/frontend/tests/unit/core/api/fetcher.test.ts index 75cf12d1d8..1675c6ddf0 100644 --- a/frontend/tests/unit/core/api/fetcher.test.ts +++ b/frontend/tests/unit/core/api/fetcher.test.ts @@ -141,4 +141,23 @@ describe("api fetcher Request inputs", () => { expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("init-token"); expect(await sent[0]?.text()).toBe("payload"); }); + + it.each(["%E0%A4%A", "abc%", "a+b%zz"])( + "forwards a malformed CSRF cookie verbatim instead of throwing: %s", + async (cookieValue) => { + document.cookie = `csrf_token=${cookieValue}`; + + await apiFetch("http://localhost/api/threads", { method: "POST" }); + + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe(cookieValue); + }, + ); + + it("still decodes a percent-encoded CSRF cookie", async () => { + document.cookie = "csrf_token=cookie%2Dtoken"; + + await apiFetch("http://localhost/api/threads", { method: "POST" }); + + expect(sent[0]?.headers.get("X-CSRF-Token")).toBe("cookie-token"); + }); });