From 52f6ff078cb81e5316d3c00984f03a669a4cbfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E7=89=87=E4=B8=80=E7=89=87?= <3037678834@qq.com> Date: Tue, 27 Oct 2026 09:47:24 +0800 Subject: [PATCH] fix(api): survive a malformed CSRF cookie (#7589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): survive a malformed CSRF cookie `readCsrfCookie` percent-decodes a cookie the gateway sets verbatim. Cookies are shared by every app on the same host (ports included), so a stray or truncated `%` escape from another local app, a proxy, or a stale value makes `decodeURIComponent` throw `URIError` inside `fetch` and `getCsrfHeaders` — every state-changing call fails with an exception before the gateway can answer 403. Fall back to the raw cookie value when decoding fails, matching `getLocaleFromCookie` (#7553). * test(api): exercise sibling CSRF cookies in the DOM --------- Co-authored-by: Willem Jiang --- frontend/src/core/api/fetcher.ts | 11 ++++++- .../tests/unit/core/api/fetcher.dom.test.ts | 29 +++++++++++++++++++ frontend/tests/unit/core/api/fetcher.test.ts | 19 ++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) 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"); + }); });