From 46cac582abf118fb1c31504b56a4e32da51cd27f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 05:08:00 +0200 Subject: [PATCH] fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a 403 to an unsafe method the client cleared its cached CSRF token and called ensureCsrfToken to retry. But ensureCsrfToken prefers the 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. --- frontend/src/api/http-init.ts | 7 ++++-- frontend/src/test/http-init-msw.test.ts | 29 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/http-init.ts b/frontend/src/api/http-init.ts index 2ead50f9f..2261d0d90 100644 --- a/frontend/src/api/http-init.ts +++ b/frontend/src/api/http-init.ts @@ -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) { diff --git a/frontend/src/test/http-init-msw.test.ts b/frontend/src/test/http-init-msw.test.ts index a70c7f00e..ab12bd555 100644 --- a/frontend/src/test/http-init-msw.test.ts +++ b/frontend/src/test/http-init-msw.test.ts @@ -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(