From 6701d09df5f06ec16042c2dd226c743f4c0dfaf6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 19:02:28 +0200 Subject: [PATCH] feat(client): refresh CSRF if request fails --- apps/client/src/services/server.ts | 79 ++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index 627e28622a..bd9edce161 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -93,7 +93,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string, met const formData = new FormData(); formData.append("upload", fileToUpload); - return await $.ajax({ + const doUpload = async () => $.ajax({ url: window.glob.baseApiUrl + url, headers: await getHeaders(componentId ? { "trilium-component-id": componentId @@ -104,6 +104,18 @@ async function upload(url: string, fileToUpload: File, componentId?: string, met contentType: false, // NEEDED, DON'T REMOVE THIS processData: false // NEEDED, DON'T REMOVE THIS }); + + try { + return await doUpload(); + } catch (e: unknown) { + // jQuery rejects with the jqXHR object + const jqXhr = e as JQuery.jqXHR; + if (jqXhr?.status && isCsrfError(jqXhr.status, jqXhr.responseText)) { + await refreshCsrfToken(); + return await doUpload(); + } + throw e; + } } let idCounter = 1; @@ -112,12 +124,55 @@ const idToRequestMap: Record = {}; let maxKnownEntityChangeId = 0; +let csrfRefreshInProgress: Promise | null = null; + +/** + * Re-fetches /bootstrap to obtain a fresh CSRF token. This is needed when the + * server session expires (e.g. mobile tab backgrounded for a long time) and the + * existing CSRF token is no longer valid. + * + * Coalesces concurrent calls so only one bootstrap request is in-flight at a time. + */ +async function refreshCsrfToken(): Promise { + if (csrfRefreshInProgress) { + return csrfRefreshInProgress; + } + + csrfRefreshInProgress = (async () => { + try { + const response = await fetch(`./bootstrap${window.location.search}`); + if (response.ok) { + const json = await response.json(); + glob.csrfToken = json.csrfToken; + } + } finally { + csrfRefreshInProgress = null; + } + })(); + + return csrfRefreshInProgress; +} + +function isCsrfError(status: number, responseText: string): boolean { + if (status !== 403) { + return false; + } + try { + const body = JSON.parse(responseText); + return body.message === "Invalid CSRF token"; + } catch { + return false; + } +} + interface CallOptions { data?: unknown; silentNotFound?: boolean; silentInternalServerError?: boolean; // If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. raw?: boolean; + /** Used internally to prevent infinite retry loops on CSRF refresh. */ + csrfRetried?: boolean; } async function call(method: string, url: string, componentId?: string, options: CallOptions = {}) { @@ -167,7 +222,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts type: method, headers, timeout: 60000, - success: (body, textStatus, jqXhr) => { + success: (body, _textStatus, jqXhr) => { const respHeaders: Headers = {}; jqXhr @@ -192,7 +247,25 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts // don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page. rej("rejected by browser"); return; - } else if (opts.silentNotFound && jqXhr.status === 404) { + } + + // If the CSRF token is stale (e.g. session expired while tab was backgrounded), + // refresh it and retry the request once. + if (!opts.csrfRetried && isCsrfError(jqXhr.status, jqXhr.responseText)) { + try { + await refreshCsrfToken(); + // Rebuild headers so the fresh glob.csrfToken is picked up + const retryHeaders = await getHeaders({ "trilium-component-id": headers["trilium-component-id"] }); + const retryResult = await ajax(url, method, data, retryHeaders, { ...opts, csrfRetried: true }); + res(retryResult); + return; + } catch (retryErr) { + rej(retryErr); + return; + } + } + + if (opts.silentNotFound && jqXhr.status === 404) { // report nothing } else if (opts.silentInternalServerError && jqXhr.status === 500) { // report nothing