CSRF fixes (#9067)
Some checks failed
Checks / main (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Dev / Test development (push) Waiting to run
Dev / Build Docker image (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile) (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile.alpine) (push) Blocked by required conditions
/ Check Docker build (Dockerfile) (push) Waiting to run
/ Check Docker build (Dockerfile.alpine) (push) Waiting to run
/ Build Docker images (Dockerfile, ubuntu-24.04-arm, linux/arm64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.alpine, ubuntu-latest, linux/amd64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v7) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v8) (push) Blocked by required conditions
/ Merge manifest lists (push) Blocked by required conditions
playwright / E2E tests on linux-arm64 (push) Waiting to run
playwright / E2E tests on linux-x64 (push) Waiting to run
Deploy website / Build & deploy website (push) Waiting to run
Deploy web clipper extension / Build web clipper extension (push) Has been cancelled

This commit is contained in:
Elian Doran 2026-03-15 20:13:46 +02:00 committed by GitHub
commit b138fedd35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 87 additions and 4 deletions

View File

@ -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<string, RequestData> = {};
let maxKnownEntityChangeId = 0;
let csrfRefreshInProgress: Promise<void> | 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<void> {
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<T>(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

View File

@ -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 });

View File

@ -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;