diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index 627e28622a..d9b776babc 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}`, { cache: "no-store" }); + 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 diff --git a/apps/server-e2e/src/layout/tab_bar.spec.ts b/apps/server-e2e/src/layout/tab_bar.spec.ts index c408114a0d..8ab1cf7c46 100644 --- a/apps/server-e2e/src/layout/tab_bar.spec.ts +++ b/apps/server-e2e/src/layout/tab_bar.spec.ts @@ -75,8 +75,10 @@ test("Tabs are restored in right order", async ({ page, context }) => { await expect(app.getActiveTab()).toContainText("Mermaid"); // Select the mid one. + const recentNotesSaved = page.waitForResponse((resp) => resp.url().includes("/api/recent-notes") && resp.ok()); await (await app.getTab(1)).click(); await expect(app.noteTreeActiveNote).toContainText("Text notes"); + await recentNotesSaved; // Refresh the page and check the order. await app.goto( { preserveTabs: true }); diff --git a/apps/server/src/routes/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index b7c5f8d5cb..684d1ec81e 100644 --- a/apps/server/src/routes/csrf_protection.ts +++ b/apps/server/src/routes/csrf_protection.ts @@ -1,9 +1,17 @@ +import crypto from "crypto"; import { doubleCsrf } from "csrf-csrf"; + import sessionSecret from "../services/session_secret.js"; import { isElectron } from "../services/utils.js"; export const CSRF_COOKIE_NAME = "trilium-csrf"; +// In Electron, API calls go through an IPC bypass (routes/electron.ts) that uses a +// FakeRequest with a static session ID, while the bootstrap request goes through real +// Express with a real session. This mismatch causes CSRF validation to always fail. +// We use a per-instance random identifier so each Electron process still gets unique tokens. +const electronSessionId = crypto.randomUUID(); + const doubleCsrfUtilities = doubleCsrf({ getSecret: () => sessionSecret, cookieOptions: { @@ -13,7 +21,7 @@ const doubleCsrfUtilities = doubleCsrf({ httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966 }, cookieName: CSRF_COOKIE_NAME, - getSessionIdentifier: (req) => req.session.id + getSessionIdentifier: (req) => isElectron ? electronSessionId : req.session.id }); export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities;