From 2c1517d2591735481f3ce823933c7138ad471d0c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 16:12:11 +0000 Subject: [PATCH 1/6] chore(deps): update dependency csrf-csrf to v4 --- apps/server/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 21de08d9b1..2ffcef29cd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -63,7 +63,7 @@ "cls-hooked": "4.2.2", "compression": "1.8.0", "cookie-parser": "1.4.7", - "csrf-csrf": "3.2.2", + "csrf-csrf": "4.0.2", "dayjs": "1.11.13", "debounce": "2.2.0", "debug": "4.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e29d29f389..982a81bae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,8 +621,8 @@ importers: specifier: 13.0.0 version: 13.0.0(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.4)(webpack-cli@6.0.1)) csrf-csrf: - specifier: 3.2.2 - version: 3.2.2 + specifier: 4.0.2 + version: 4.0.2 dayjs: specifier: 1.11.13 version: 1.11.13 @@ -6947,8 +6947,8 @@ packages: resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==} engines: {node: '>=12.10'} - csrf-csrf@3.2.2: - resolution: {integrity: sha512-E3TgLWX1e+jqigDva+nFItfqa59UZ+gLR56DVNyL/xawBGwQr8o3U4/o1gP9FZmIWLnWCiIl5ni85MghMCNRfg==} + csrf-csrf@4.0.2: + resolution: {integrity: sha512-jWI4uDjZn1EedVSa6WhiL6L6M5XmSemXLgCDGwrdPLtkCThSDDTj4ewokTTqrW8JZYcfJ3oY4LFCtXgQ2XAg5Q==} css-declaration-sorter@6.4.1: resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} @@ -21865,7 +21865,7 @@ snapshots: cross-zip@4.0.1: {} - csrf-csrf@3.2.2: + csrf-csrf@4.0.2: dependencies: http-errors: 2.0.0 From 6f6041ee7b954fe70e5cf0615e04ca6a716d6a44 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 15 May 2025 20:39:31 +0300 Subject: [PATCH 2/6] fix(server): migrate csrf to v4 --- apps/server/src/routes/csrf_protection.ts | 5 +++-- apps/server/src/routes/index.ts | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/routes/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index 391be0aaa7..bd382e3691 100644 --- a/apps/server/src/routes/csrf_protection.ts +++ b/apps/server/src/routes/csrf_protection.ts @@ -10,7 +10,8 @@ const doubleCsrfUtilities = doubleCsrf({ sameSite: "strict", httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Notes/pull/966 }, - cookieName: "_csrf" + cookieName: "_csrf", + getSessionIdentifier: (req) => req.session.id }); -export const { generateToken, doubleCsrfProtection } = doubleCsrfUtilities; +export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities; diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 79a40f1864..5a907cee48 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -10,7 +10,7 @@ import protectedSessionService from "../services/protected_session.js"; import packageJson from "../../package.json" with { type: "json" }; import assetPath from "../services/asset_path.js"; import appPath from "../services/app_path.js"; -import { generateToken as generateCsrfToken } from "./csrf_protection.js"; +import { generateCsrfToken } from "./csrf_protection.js"; import type { Request, Response } from "express"; import type BNote from "../becca/entities/bnote.js"; @@ -19,9 +19,10 @@ function index(req: Request, res: Response) { const options = optionService.getOptionMap(); const view = getView(req); - //'overwrite' set to false (default) => the existing token will be re-used and validated - //'validateOnReuse' set to false => if validation fails, generate a new token instead of throwing an error - const csrfToken = generateCsrfToken(req, res, false, false); + const csrfToken = generateCsrfToken(req, res, { + overwrite: true, + validateOnReuse: false // if validation fails, generate a new token instead of throwing an error + }); log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`); // We force the page to not be cached since on mobile the CSRF token can be From f327b54c0e1e62d3e9d8d091f36c7cac0d938e75 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 16 May 2025 19:45:32 +0300 Subject: [PATCH 3/6] feat(csrf): use different token to avoid issues with old token --- apps/server/src/routes/csrf_protection.ts | 4 +++- apps/server/src/routes/error_handlers.ts | 3 ++- apps/server/src/routes/index.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index bd382e3691..2b26afbf3e 100644 --- a/apps/server/src/routes/csrf_protection.ts +++ b/apps/server/src/routes/csrf_protection.ts @@ -2,6 +2,8 @@ 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"; + const doubleCsrfUtilities = doubleCsrf({ getSecret: () => sessionSecret, cookieOptions: { @@ -10,7 +12,7 @@ const doubleCsrfUtilities = doubleCsrf({ sameSite: "strict", httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Notes/pull/966 }, - cookieName: "_csrf", + cookieName: CSRF_COOKIE_NAME, getSessionIdentifier: (req) => req.session.id }); diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts index 05b05f6a4a..af58be82f1 100644 --- a/apps/server/src/routes/error_handlers.ts +++ b/apps/server/src/routes/error_handlers.ts @@ -3,6 +3,7 @@ import log from "../services/log.js"; import NotFoundError from "../errors/not_found_error.js"; import ForbiddenError from "../errors/forbidden_error.js"; import HttpError from "../errors/http_error.js"; +import { CSRF_COOKIE_NAME } from "./csrf_protection.js"; function register(app: Application) { @@ -14,7 +15,7 @@ function register(app: Application) { && err.code === "EBADCSRFTOKEN"; if (isCsrfTokenError) { - log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`); + log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies[CSRF_COOKIE_NAME]}`); return next(new ForbiddenError("Invalid CSRF token")); } diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 5a907cee48..60697b156a 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -20,7 +20,7 @@ function index(req: Request, res: Response) { const view = getView(req); const csrfToken = generateCsrfToken(req, res, { - overwrite: true, + overwrite: false, validateOnReuse: false // if validation fails, generate a new token instead of throwing an error }); log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`); From c8a546ef1e017ea6fd772810db8f905abb080485 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Mar 2026 23:31:17 +0200 Subject: [PATCH 4/6] fix(server): uninitialized sessions causing bad CSRF errors --- apps/server/src/routes/session_parser.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/session_parser.ts b/apps/server/src/routes/session_parser.ts index a52a9183aa..f0f0159d24 100644 --- a/apps/server/src/routes/session_parser.ts +++ b/apps/server/src/routes/session_parser.ts @@ -102,7 +102,11 @@ export const sessionStore = new SQLiteSessionStore(); const sessionParser: express.RequestHandler = session({ secret: sessionSecret, resave: false, // true forces the session to be saved back to the session store, even if the session was never modified during the request. - saveUninitialized: false, // true forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. + saveUninitialized: true, // Ensures new sessions are always persisted and the session cookie is sent on the first response. + // This is required for csrf-csrf v4, which binds CSRF tokens to the session ID via HMAC. + // With saveUninitialized: false, a brand-new session would never be saved (and its cookie + // never sent) when noAuthentication=true, causing every request to get a different ephemeral + // session ID and making CSRF validation fail for all API calls. rolling: true, // forces the session to be saved back to the session store, resetting the expiration date. cookie: { path: "/", From 0d889426e814d4d9822d2198ac6854ab969c7265 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Mar 2026 23:48:06 +0200 Subject: [PATCH 5/6] refactor(server): use different approach to handling the CSRF token --- apps/server/src/express.d.ts | 2 ++ apps/server/src/routes/index.ts | 9 +++++++++ apps/server/src/routes/session_parser.ts | 6 +----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/express.d.ts b/apps/server/src/express.d.ts index 781c6db551..5b9673d693 100644 --- a/apps/server/src/express.d.ts +++ b/apps/server/src/express.d.ts @@ -26,5 +26,7 @@ export declare module "express-session" { totpEnabled: boolean; ssoEnabled: boolean; }; + /** Set during /bootstrap to mark the session as modified so express-session persists it and sends the cookie. */ + csrfInitialized?: true; } } diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index eee290ff29..8094c0bdf6 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -20,6 +20,15 @@ type View = "desktop" | "mobile" | "print"; export function bootstrap(req: Request, res: Response) { const options = optionService.getOptionMap(); + // csrf-csrf v4 binds CSRF tokens to the session ID via HMAC. With saveUninitialized: false, + // a brand-new session is never persisted unless explicitly modified, so its cookie is never + // sent to the browser — meaning every request gets a different ephemeral session ID, and + // CSRF validation fails. Setting this flag marks the session as modified, which causes + // express-session to persist it and send the session cookie in this response. + if (!req.session.csrfInitialized) { + req.session.csrfInitialized = true; + } + const csrfToken = generateCsrfToken(req, res, { overwrite: false, validateOnReuse: false // if validation fails, generate a new token instead of throwing an error diff --git a/apps/server/src/routes/session_parser.ts b/apps/server/src/routes/session_parser.ts index f0f0159d24..a52a9183aa 100644 --- a/apps/server/src/routes/session_parser.ts +++ b/apps/server/src/routes/session_parser.ts @@ -102,11 +102,7 @@ export const sessionStore = new SQLiteSessionStore(); const sessionParser: express.RequestHandler = session({ secret: sessionSecret, resave: false, // true forces the session to be saved back to the session store, even if the session was never modified during the request. - saveUninitialized: true, // Ensures new sessions are always persisted and the session cookie is sent on the first response. - // This is required for csrf-csrf v4, which binds CSRF tokens to the session ID via HMAC. - // With saveUninitialized: false, a brand-new session would never be saved (and its cookie - // never sent) when noAuthentication=true, causing every request to get a different ephemeral - // session ID and making CSRF validation fail for all API calls. + saveUninitialized: false, // true forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. rolling: true, // forces the session to be saved back to the session store, resetting the expiration date. cookie: { path: "/", From f6f939c245879230b67906383ce704c9937f1edd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Mar 2026 23:49:36 +0200 Subject: [PATCH 6/6] chore(server): address requested changes --- apps/server/src/routes/error_handlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts index af58be82f1..9df5f9c283 100644 --- a/apps/server/src/routes/error_handlers.ts +++ b/apps/server/src/routes/error_handlers.ts @@ -15,7 +15,10 @@ function register(app: Application) { && err.code === "EBADCSRFTOKEN"; if (isCsrfTokenError) { - log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies[CSRF_COOKIE_NAME]}`); + const csrfHeader = req.headers["x-csrf-token"]; + const csrfHeaderPrefix = typeof csrfHeader === "string" ? csrfHeader.slice(0, 8) : undefined; + const tokenInfo = csrfHeaderPrefix ? ` (token prefix: ${csrfHeaderPrefix})` : ""; + log.error(`Invalid CSRF token on ${req.method} ${req.url}${tokenInfo}`); return next(new ForbiddenError("Invalid CSRF token")); }