fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag

On a 403 to an unsafe method the client cleared its cached CSRF token and
called ensureCsrfToken to retry. But ensureCsrfToken prefers the
<meta name="csrf-token"> tag baked into the page, which the production
panel always injects, so the "refresh" re-read the same stale token and the
/csrf-token refetch was never reached — the retry re-sent the token that had
just been rejected and the save failed with an error toast.

The token lives in the session and rotates when the session is regenerated
(for example re-login in another tab), leaving the tab's baked-in meta token
stale. Fetch the current token straight from /csrf-token in the 403 branch so
the retry uses the authoritative server value. The existing tests only passed
because they strip the meta tag; the new test keeps a stale tag present.
This commit is contained in:
MHSanaei
2026-07-15 05:08:00 +02:00
parent 588a524fa5
commit 46cac582ab
2 changed files with 34 additions and 2 deletions
+5 -2
View File
@@ -152,8 +152,11 @@ export async function httpRequest(
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
csrfToken = null;
const fresh = await ensureCsrfToken();
if (fresh) res = await performFetch(method, url, data, options, fresh);
const fresh = await fetchCsrfToken();
if (fresh) {
csrfToken = fresh;
res = await performFetch(method, url, data, options, fresh);
}
}
if (res.status === 401) {
+29
View File
@@ -41,6 +41,35 @@ describe('httpRequest against the MSW-mocked network', () => {
expect(res.data).toEqual({ success: true, obj: 'saved' });
});
it('on a 403 refetches a fresh token from the server even when a stale meta tag is present', async () => {
const STALE = 'stale-meta-token';
const FRESH = 'fresh-server-token';
vi.stubGlobal('document', {
querySelector: (selector: string) =>
selector === 'meta[name="csrf-token"]' ? { getAttribute: () => STALE } : null,
});
setupHttp();
let posts = 0;
const sentTokens: (string | null)[] = [];
server.use(
http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: FRESH })),
http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
posts += 1;
const token = request.headers.get('X-CSRF-Token');
sentTokens.push(token);
if (token !== FRESH) return new HttpResponse(null, { status: 403 });
return HttpResponse.json({ success: true, obj: 'saved' });
}),
);
const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
expect(sentTokens).toEqual([STALE, FRESH]);
expect(posts).toBe(2);
expect(res.status).toBe(200);
});
it('resolves a safe GET without requesting a CSRF token', async () => {
let csrfHits = 0;
server.use(