diff --git a/apps/server/package.json b/apps/server/package.json index 1ea1c1423f..6c05b43c7b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -78,7 +78,7 @@ "cls-hooked": "4.2.2", "compression": "1.8.1", "cookie-parser": "1.4.7", - "csrf-csrf": "3.2.2", + "csrf-csrf": "4.0.2", "debounce": "3.0.0", "debug": "4.4.3", "ejs": "5.0.1", 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/csrf_protection.ts b/apps/server/src/routes/csrf_protection.ts index 0b0ba1aac8..b7c5f8d5cb 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,8 @@ const doubleCsrfUtilities = doubleCsrf({ sameSite: "strict", httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966 }, - cookieName: "_csrf" + cookieName: CSRF_COOKIE_NAME, + getSessionIdentifier: (req) => req.session.id }); -export const { generateToken, doubleCsrfProtection } = doubleCsrfUtilities; +export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities; diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts index 05b05f6a4a..9df5f9c283 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,10 @@ function register(app: Application) { && err.code === "EBADCSRFTOKEN"; if (isCsrfTokenError) { - log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`); + 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")); } diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 8f96e5124b..8094c0bdf6 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -11,19 +11,28 @@ import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPP import log from "../services/log.js"; import optionService from "../services/options.js"; import protectedSessionService from "../services/protected_session.js"; +import { generateCsrfToken } from "./csrf_protection.js"; import sql from "../services/sql.js"; import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js"; -import { generateToken as generateCsrfToken } from "./csrf_protection.js"; - type View = "desktop" | "mobile" | "print"; export function bootstrap(req: Request, res: Response) { const options = optionService.getOptionMap(); - //'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); + // 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 + }); log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`); const view = getView(req); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2133621e1..6c0aceac33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -698,8 +698,8 @@ importers: specifier: 1.4.7 version: 1.4.7 csrf-csrf: - specifier: 3.2.2 - version: 3.2.2 + specifier: 4.0.2 + version: 4.0.2 debounce: specifier: 3.0.0 version: 3.0.0 @@ -8778,8 +8778,8 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - csrf-csrf@3.2.2: - resolution: {integrity: sha512-E3TgLWX1e+jqigDva+nFItfqa59UZ+gLR56DVNyL/xawBGwQr8o3U4/o1gP9FZmIWLnWCiIl5ni85MghMCNRfg==} + csrf-csrf@4.0.2: + resolution: {integrity: sha512-jWI4uDjZn1EedVSa6WhiL6L6M5XmSemXLgCDGwrdPLtkCThSDDTj4ewokTTqrW8JZYcfJ3oY4LFCtXgQ2XAg5Q==} css-color-keywords@1.0.0: resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} @@ -17214,8 +17214,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.6.1 '@ckeditor/ckeditor5-upload': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-ai@47.6.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -17357,16 +17355,12 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-cloud-services@47.6.1': dependencies: '@ckeditor/ckeditor5-core': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-code-block@47.6.1(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -17564,8 +17558,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-editor-decoupled@47.6.1': dependencies: @@ -17575,8 +17567,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-editor-inline@47.6.1': dependencies: @@ -17586,8 +17576,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-editor-multi-root@47.6.1': dependencies: @@ -17690,8 +17678,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-font@47.6.1': dependencies: @@ -17767,8 +17753,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-embed@47.6.1': dependencies: @@ -17795,8 +17779,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-icons@47.6.1': {} @@ -17953,6 +17935,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-merge-fields@47.6.1': dependencies: @@ -17973,6 +17957,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-operations-compressor@47.6.1': dependencies: @@ -18025,6 +18011,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-pagination@47.6.1': dependencies: @@ -18088,6 +18076,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-restricted-editing@47.6.1': dependencies: @@ -18144,6 +18134,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-source-editing-enhanced@47.6.1': dependencies: @@ -18191,6 +18183,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-table@47.6.1': dependencies: @@ -18203,6 +18197,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-template@47.6.1': dependencies: @@ -18312,6 +18308,8 @@ snapshots: '@ckeditor/ckeditor5-engine': 47.6.1 '@ckeditor/ckeditor5-utils': 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-widget@47.6.1': dependencies: @@ -18331,6 +18329,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.6.1 ckeditor5: 47.6.1 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@codemirror/autocomplete@6.18.6': dependencies: @@ -26514,9 +26514,9 @@ snapshots: crypto-js@4.2.0: {} - csrf-csrf@3.2.2: + csrf-csrf@4.0.2: dependencies: - http-errors: 2.0.0 + http-errors: 2.0.1 css-color-keywords@1.0.0: {} @@ -31350,7 +31350,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 npm-normalize-package-bin@4.0.0: {}