From e36d7121f19807209a88bb760c1a9404db46cd2c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 18:54:37 +0200 Subject: [PATCH 1/5] fix(desktop): broken due to CSRF failing --- apps/server/src/routes/csrf_protection.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index b7c5f8d5cb..17cba503a2 100644 --- a/apps/server/src/routes/csrf_protection.ts +++ b/apps/server/src/routes/csrf_protection.ts @@ -1,4 +1,5 @@ import { doubleCsrf } from "csrf-csrf"; + import sessionSecret from "../services/session_secret.js"; import { isElectron } from "../services/utils.js"; @@ -13,7 +14,11 @@ 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 + // 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. + // Since Electron is a local single-user app, a constant identifier is acceptable here. + getSessionIdentifier: (req) => isElectron ? "electron" : req.session.id }); export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities; From 6701d09df5f06ec16042c2dd226c743f4c0dfaf6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 19:02:28 +0200 Subject: [PATCH 2/5] 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 From 495145e033d717b9ec39aa8763c0a66414093ee2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 19:33:06 +0200 Subject: [PATCH 3/5] chore(server): use random UUID for session ID --- apps/server/src/routes/csrf_protection.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index 17cba503a2..684d1ec81e 100644 --- a/apps/server/src/routes/csrf_protection.ts +++ b/apps/server/src/routes/csrf_protection.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { doubleCsrf } from "csrf-csrf"; import sessionSecret from "../services/session_secret.js"; @@ -5,6 +6,12 @@ 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: { @@ -14,11 +21,7 @@ const doubleCsrfUtilities = doubleCsrf({ httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966 }, cookieName: CSRF_COOKIE_NAME, - // 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. - // Since Electron is a local single-user app, a constant identifier is acceptable here. - getSessionIdentifier: (req) => isElectron ? "electron" : req.session.id + getSessionIdentifier: (req) => isElectron ? electronSessionId : req.session.id }); export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities; From 53739ee8d425a6263a9b198a17b875dca83689f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 19:33:21 +0200 Subject: [PATCH 4/5] e2e(server): address flaky test --- apps/server-e2e/src/layout/tab_bar.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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 }); From 7a544482d17f5b470c3602fa3595abb565ddd61b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 15 Mar 2026 19:39:27 +0200 Subject: [PATCH 5/5] chore(server): request bootstrap with no cache --- apps/client/src/services/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index bd9edce161..d9b776babc 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -140,7 +140,7 @@ async function refreshCsrfToken(): Promise { csrfRefreshInProgress = (async () => { try { - const response = await fetch(`./bootstrap${window.location.search}`); + const response = await fetch(`./bootstrap${window.location.search}`, { cache: "no-store" }); if (response.ok) { const json = await response.json(); glob.csrfToken = json.csrfToken;