fix(api): survive a malformed CSRF cookie (#7589)

* 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 <willem.jiang@gmail.com>
This commit is contained in:
一片一片 2026-10-27 09:47:24 +08:00 committed by GitHub
parent ab8cd4e10f
commit 52f6ff078c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 58 additions and 1 deletions

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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");
});
});