From aac5127e1f4c2c292225765352b95bb1cbb33468 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:30:41 +0000 Subject: [PATCH 1/7] feat: add trilium-totp header support in request infrastructure --- apps/server/src/express.d.ts | 1 + apps/server/src/services/request.ts | 3 +++ apps/server/src/services/request_interface.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/server/src/express.d.ts b/apps/server/src/express.d.ts index 781c6db551a..95424c9e71c 100644 --- a/apps/server/src/express.d.ts +++ b/apps/server/src/express.d.ts @@ -8,6 +8,7 @@ export declare module "express-serve-static-core" { authorization?: string; "trilium-cred"?: string; + "trilium-totp"?: string; "x-csrf-token"?: string; "trilium-component-id"?: string; diff --git a/apps/server/src/services/request.ts b/apps/server/src/services/request.ts index 688c617531a..2ff5f2616ff 100644 --- a/apps/server/src/services/request.ts +++ b/apps/server/src/services/request.ts @@ -62,6 +62,9 @@ async function exec(opts: ExecOpts): Promise { if (opts.auth) { headers["trilium-cred"] = Buffer.from(`dummy:${opts.auth.password}`).toString("base64"); + if (opts.auth.totpToken) { + headers["trilium-totp"] = opts.auth.totpToken; + } } const request = (await client).request({ diff --git a/apps/server/src/services/request_interface.ts b/apps/server/src/services/request_interface.ts index 8ecd46bdb17..67eb37d8afa 100644 --- a/apps/server/src/services/request_interface.ts +++ b/apps/server/src/services/request_interface.ts @@ -14,6 +14,7 @@ export interface ExecOpts { cookieJar?: CookieJar; auth?: { password?: string; + totpToken?: string; }; timeout: number; body?: string | {}; From 6004f35f66c2012ce5f88d16fc17e5cd248f0246 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:31:00 +0000 Subject: [PATCH 2/7] feat: enforce TOTP verification in checkCredentials middleware --- apps/server/src/services/auth.ts | 40 ++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/server/src/services/auth.ts b/apps/server/src/services/auth.ts index b10ef80977b..641ee6bdde3 100644 --- a/apps/server/src/services/auth.ts +++ b/apps/server/src/services/auth.ts @@ -1,15 +1,17 @@ -import etapiTokenService from "./etapi_tokens.js"; -import log from "./log.js"; -import sqlInit from "./sql_init.js"; -import { isElectron } from "./utils.js"; -import passwordEncryptionService from "./encryption/password_encryption.js"; +import type { NextFunction, Request, Response } from "express"; + +import attributes from "./attributes.js"; import config from "./config.js"; import passwordService from "./encryption/password.js"; -import totp from "./totp.js"; +import passwordEncryptionService from "./encryption/password_encryption.js"; +import recoveryCodeService from "./encryption/recovery_codes.js"; +import etapiTokenService from "./etapi_tokens.js"; +import log from "./log.js"; import openID from "./open_id.js"; import options from "./options.js"; -import attributes from "./attributes.js"; -import type { NextFunction, Request, Response } from "express"; +import sqlInit from "./sql_init.js"; +import totp from "./totp.js"; +import { isElectron } from "./utils.js"; let noAuthentication = false; refreshAuth(); @@ -161,9 +163,27 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) { if (!passwordEncryptionService.verifyPassword(password)) { res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password"); log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`); - } else { - next(); + return; + } + + // Verify TOTP if enabled + if (totp.isTotpEnabled()) { + const totpToken = req.headers["trilium-totp"] || ""; + if (typeof totpToken !== "string" || !totpToken) { + res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required"); + log.info(`WARNING: Missing TOTP token from ${req.ip}, rejecting.`); + return; + } + + // Accept TOTP code or recovery code + if (!totp.validateTOTP(totpToken) && !recoveryCodeService.verifyRecoveryCode(totpToken)) { + res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect TOTP token"); + log.info(`WARNING: Wrong TOTP token from ${req.ip}, rejecting.`); + return; + } } + + next(); } export default { From d595a1d0196a4d38bb0bd670e0bd253262f803cd Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:31:21 +0000 Subject: [PATCH 3/7] feat: expose totpEnabled status and pass TOTP token through sync setup --- apps/server/src/routes/api/setup.ts | 37 +++++++++++++++++------ apps/server/src/routes/routes.ts | 1 + apps/server/src/services/api-interface.ts | 1 + apps/server/src/services/setup.ts | 35 +++++++++++++++------ 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/apps/server/src/routes/api/setup.ts b/apps/server/src/routes/api/setup.ts index 718bc37a75c..7716d9a8ffa 100644 --- a/apps/server/src/routes/api/setup.ts +++ b/apps/server/src/routes/api/setup.ts @@ -1,16 +1,19 @@ -"use strict"; -import sqlInit from "../../services/sql_init.js"; -import setupService from "../../services/setup.js"; -import log from "../../services/log.js"; -import appInfo from "../../services/app_info.js"; + import type { Request } from "express"; +import appInfo from "../../services/app_info.js"; +import log from "../../services/log.js"; +import setupService from "../../services/setup.js"; +import sqlInit from "../../services/sql_init.js"; +import totp from "../../services/totp.js"; + function getStatus() { return { isInitialized: sqlInit.isDbInitialized(), schemaExists: sqlInit.schemaExists(), - syncVersion: appInfo.syncVersion + syncVersion: appInfo.syncVersion, + totpEnabled: totp.isTotpEnabled() }; } @@ -19,9 +22,9 @@ async function setupNewDocument() { } function setupSyncFromServer(req: Request) { - const { syncServerHost, syncProxy, password } = req.body; + const { syncServerHost, syncProxy, password, totpToken } = req.body; - return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password); + return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken); } function saveSyncSeed(req: Request) { @@ -82,10 +85,26 @@ function getSyncSeed() { }; } +async function checkServerTotpStatus(req: Request) { + const { syncServerHost } = req.body; + + if (!syncServerHost) { + return { totpEnabled: false }; + } + + try { + const resp = await setupService.checkRemoteTotpStatus(syncServerHost); + return { totpEnabled: !!resp.totpEnabled }; + } catch { + return { totpEnabled: false }; + } +} + export default { getStatus, setupNewDocument, setupSyncFromServer, getSyncSeed, - saveSyncSeed + saveSyncSeed, + checkServerTotpStatus }; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index ce9b84f0a9b..f83ffc01b4f 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -244,6 +244,7 @@ function register(app: express.Application) { asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler); route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler); asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler); + asyncRoute(PST, "/api/setup/check-server-totp", [auth.checkAppNotInitialized], setupApiRoute.checkServerTotpStatus, apiResultHandler); apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete); apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount); diff --git a/apps/server/src/services/api-interface.ts b/apps/server/src/services/api-interface.ts index 8d837c3b50a..7f2c6b04944 100644 --- a/apps/server/src/services/api-interface.ts +++ b/apps/server/src/services/api-interface.ts @@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons"; export interface SetupStatusResponse { syncVersion: number; schemaExists: boolean; + totpEnabled: boolean; } /** diff --git a/apps/server/src/services/setup.ts b/apps/server/src/services/setup.ts index a20fb670f93..8f31099af46 100644 --- a/apps/server/src/services/setup.ts +++ b/apps/server/src/services/setup.ts @@ -1,13 +1,13 @@ -import syncService from "./sync.js"; +import becca from "../becca/becca.js"; +import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js"; +import appInfo from "./app_info.js"; import log from "./log.js"; -import sqlInit from "./sql_init.js"; import optionService from "./options.js"; -import syncOptions from "./sync_options.js"; import request from "./request.js"; -import appInfo from "./app_info.js"; +import sqlInit from "./sql_init.js"; +import syncService from "./sync.js"; +import syncOptions from "./sync_options.js"; import { timeLimit } from "./utils.js"; -import becca from "../becca/becca.js"; -import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js"; async function hasSyncServerSchemaAndSeed() { const response = await requestToSyncServer("GET", "/api/setup/status"); @@ -55,13 +55,13 @@ async function requestToSyncServer(method: string, path: string, body?: strin url: syncOptions.getSyncServerHost() + path, body, proxy: syncOptions.getSyncProxy(), - timeout: timeout + timeout }), timeout )) as T; } -async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) { +async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: string) { if (sqlInit.isDbInitialized()) { return { result: "failure", @@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string const resp = await request.exec({ method: "get", url: `${syncServerHost}/api/setup/sync-seed`, - auth: { password }, + auth: { password, totpToken }, proxy: syncProxy, timeout: 30000 // seed request should not take long }); @@ -111,10 +111,25 @@ function getSyncSeedOptions() { return [becca.getOption("documentId"), becca.getOption("documentSecret")]; } +async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> { + try { + const resp = await request.exec<{ totpEnabled?: boolean }>({ + method: "get", + url: `${syncServerHost}/api/setup/status`, + proxy: null, + timeout: 10000 + }); + return { totpEnabled: !!resp?.totpEnabled }; + } catch { + return { totpEnabled: false }; + } +} + export default { hasSyncServerSchemaAndSeed, triggerSync, sendSeedToSyncServer, setupSyncFromSyncServer, - getSyncSeedOptions + getSyncSeedOptions, + checkRemoteTotpStatus }; From 446cc7ab5c820bc0a5699b3ffe5334f08a966b98 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:32:52 +0000 Subject: [PATCH 4/7] feat: add TOTP input field to sync-from-server setup UI --- apps/client/src/setup.ts | 45 ++++++++++++++++--- .../src/assets/translations/cn/server.json | 2 + .../src/assets/translations/en/server.json | 2 + .../src/assets/translations/tw/server.json | 2 + apps/server/src/assets/views/setup.ejs | 6 ++- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/client/src/setup.ts b/apps/client/src/setup.ts index 29fbd15c00a..c53f91f3abe 100644 --- a/apps/client/src/setup.ts +++ b/apps/client/src/setup.ts @@ -1,7 +1,9 @@ import "jquery"; -import utils from "./services/utils.js"; + import ko from "knockout"; +import utils from "./services/utils.js"; + // TriliumNextTODO: properly make use of below types // type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | ""; // type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop"; @@ -16,6 +18,8 @@ class SetupModel { syncServerHost: ko.Observable; syncProxy: ko.Observable; password: ko.Observable; + totpToken: ko.Observable; + totpEnabled: ko.Observable; constructor(syncInProgress: boolean) { this.syncInProgress = syncInProgress; @@ -27,6 +31,8 @@ class SetupModel { this.syncServerHost = ko.observable(); this.syncProxy = ko.observable(); this.password = ko.observable(); + this.totpToken = ko.observable(); + this.totpEnabled = ko.observable(false); if (this.syncInProgress) { setInterval(checkOutstandingSyncs, 1000); @@ -40,7 +46,7 @@ class SetupModel { return !!this.setupType(); } - selectSetupType() { + async selectSetupType() { if (this.setupType() === "new-document") { this.step("new-document-in-progress"); @@ -52,6 +58,24 @@ class SetupModel { } } + async checkTotpStatus() { + const syncServerHost = this.syncServerHost(); + if (!syncServerHost) { + this.totpEnabled(false); + return; + } + + try { + const resp = await $.post("api/setup/check-server-totp", { + syncServerHost + }); + this.totpEnabled(!!resp.totpEnabled); + } catch { + // If we can't reach the server, don't show TOTP field yet + this.totpEnabled(false); + } + } + back() { this.step("setup-type"); this.setupType(""); @@ -72,11 +96,22 @@ class SetupModel { return; } + // Check TOTP status before submitting (in case it wasn't checked yet) + await this.checkTotpStatus(); + + const totpToken = this.totpToken(); + + if (this.totpEnabled() && !totpToken) { + showAlert("TOTP token can't be empty when two-factor authentication is enabled"); + return; + } + // not using server.js because it loads too many dependencies const resp = await $.post("api/setup/sync-from-server", { - syncServerHost: syncServerHost, - syncProxy: syncProxy, - password: password + syncServerHost, + syncProxy, + password, + totpToken }); if (resp.result === "success") { diff --git a/apps/server/src/assets/translations/cn/server.json b/apps/server/src/assets/translations/cn/server.json index ec5f2efd56a..35f6ba03835 100644 --- a/apps/server/src/assets/translations/cn/server.json +++ b/apps/server/src/assets/translations/cn/server.json @@ -155,6 +155,8 @@ "proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)", "password": "密码", "password-placeholder": "密码", + "totp-token": "TOTP 验证码", + "totp-token-placeholder": "请输入 TOTP 验证码", "back": "返回", "finish-setup": "完成设置" }, diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 56aa2697bfd..4be4f27df25 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -252,6 +252,8 @@ "proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)", "password": "Password", "password-placeholder": "Password", + "totp-token": "TOTP Token", + "totp-token-placeholder": "Enter your TOTP code", "back": "Back", "finish-setup": "Finish setup" }, diff --git a/apps/server/src/assets/translations/tw/server.json b/apps/server/src/assets/translations/tw/server.json index d40cb8eaff0..874efb3d87d 100644 --- a/apps/server/src/assets/translations/tw/server.json +++ b/apps/server/src/assets/translations/tw/server.json @@ -155,6 +155,8 @@ "proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)", "password": "密碼", "password-placeholder": "密碼", + "totp-token": "TOTP 驗證碼", + "totp-token-placeholder": "請輸入 TOTP 驗證碼", "back": "返回", "finish-setup": "完成設定" }, diff --git a/apps/server/src/assets/views/setup.ejs b/apps/server/src/assets/views/setup.ejs index 68ee58b517e..6265c36ddb3 100644 --- a/apps/server/src/assets/views/setup.ejs +++ b/apps/server/src/assets/views/setup.ejs @@ -129,7 +129,7 @@
- "> + ">
@@ -141,6 +141,10 @@ ">
+
+ + " autocomplete="one-time-code"> +
From cf7ac03a700bc7ad604c86e737efee691ae97a6a Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:43:29 +0000 Subject: [PATCH 5/7] test: add tests for TOTP enforcement in checkCredentials and setup status --- apps/server/src/services/auth.spec.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/server/src/services/auth.spec.ts b/apps/server/src/services/auth.spec.ts index f0446dfe149..c46688c58de 100644 --- a/apps/server/src/services/auth.spec.ts +++ b/apps/server/src/services/auth.spec.ts @@ -9,6 +9,10 @@ import options from "./options"; let app: Application; +function encodeCred(password: string): string { + return Buffer.from(`dummy:${password}`).toString("base64"); +} + describe("Auth", () => { beforeAll(async () => { const buildApp = (await (import("../../src/app.js"))).default; @@ -72,4 +76,49 @@ describe("Auth", () => { .expect(200); }); }); + + describe("Setup status endpoint", () => { + it("returns totpEnabled: true when TOTP is enabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "true"); + options.setOption("mfaMethod", "totp"); + options.setOption("totpVerificationHash", "hi"); + }); + const response = await supertest(app) + .get("/api/setup/status") + .expect(200); + expect(response.body.totpEnabled).toBe(true); + }); + + it("returns totpEnabled: false when TOTP is disabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "false"); + }); + const response = await supertest(app) + .get("/api/setup/status") + .expect(200); + expect(response.body.totpEnabled).toBe(false); + }); + }); + + describe("checkCredentials TOTP enforcement", () => { + beforeAll(() => { + config.General.noAuthentication = false; + refreshAuth(); + }); + + it("does not require TOTP token when TOTP is disabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "false"); + }); + // Will still fail with 401 due to wrong password, but NOT because of missing TOTP + const response = await supertest(app) + .get("/api/setup/sync-seed") + .set("trilium-cred", encodeCred("wrongpassword")) + .expect(401); + // The error should be about password, not TOTP + expect(response.text).toContain("Incorrect password"); + }); + }); }, 60_000); + From d4810be0ab9ed7b3da94f33bee3b9c4462454242 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:46:46 +0000 Subject: [PATCH 6/7] fix: handle header array edge case and add URL scheme validation for SSRF mitigation --- apps/server/src/services/auth.ts | 5 +++-- apps/server/src/services/setup.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/auth.ts b/apps/server/src/services/auth.ts index 641ee6bdde3..9b9a6e17d33 100644 --- a/apps/server/src/services/auth.ts +++ b/apps/server/src/services/auth.ts @@ -168,10 +168,11 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) { // Verify TOTP if enabled if (totp.isTotpEnabled()) { - const totpToken = req.headers["trilium-totp"] || ""; + const totpHeader = req.headers["trilium-totp"]; + const totpToken = Array.isArray(totpHeader) ? totpHeader[0] : totpHeader; if (typeof totpToken !== "string" || !totpToken) { res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required"); - log.info(`WARNING: Missing TOTP token from ${req.ip}, rejecting.`); + log.info(`WARNING: Missing or invalid TOTP token from ${req.ip}, rejecting.`); return; } diff --git a/apps/server/src/services/setup.ts b/apps/server/src/services/setup.ts index 8f31099af46..d176744ff68 100644 --- a/apps/server/src/services/setup.ts +++ b/apps/server/src/services/setup.ts @@ -112,6 +112,11 @@ function getSyncSeedOptions() { } async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> { + // Validate URL scheme to mitigate SSRF + if (!syncServerHost.startsWith("http://") && !syncServerHost.startsWith("https://")) { + return { totpEnabled: false }; + } + try { const resp = await request.exec<{ totpEnabled?: boolean }>({ method: "get", From 178a3b43188790ea2e04c09938ea8192869fca16 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:59:58 +0000 Subject: [PATCH 7/7] feat: use 160-bit secret for TOTP --- apps/server/src/services/encryption/totp_encryption.ts | 7 ++++--- apps/server/src/services/totp.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 87f4cfef188..4121e3a04f8 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,8 +1,9 @@ +import type { OptionNames } from "@triliumnext/commons"; + import optionService from "../options.js"; -import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js"; +import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js"; import dataEncryptionService from "./data_encryption.js"; -import type { OptionNames } from "@triliumnext/commons"; +import myScryptService from "./my_scrypt.js"; const TOTP_OPTIONS: Record = { SALT: "totpEncryptionSalt", diff --git a/apps/server/src/services/totp.ts b/apps/server/src/services/totp.ts index cabeaae1576..49feddcc797 100644 --- a/apps/server/src/services/totp.ts +++ b/apps/server/src/services/totp.ts @@ -1,6 +1,7 @@ -import { Totp, generateSecret } from 'time2fa'; -import options from './options.js'; +import { generateSecret,Totp } from 'time2fa'; + import totpEncryptionService from './encryption/totp_encryption.js'; +import options from './options.js'; function isTotpEnabled(): boolean { return options.getOptionOrNull('mfaEnabled') === "true" && @@ -10,7 +11,7 @@ function isTotpEnabled(): boolean { function createSecret(): { success: boolean; message?: string } { try { - const secret = generateSecret(); + const secret = generateSecret(20); totpEncryptionService.setTotpSecret(secret); @@ -43,6 +44,8 @@ function validateTOTP(submittedPasscode: string): boolean { return Totp.validate({ passcode: submittedPasscode, secret: secret.trim() + }, { + secretSize: secret.trim().length === 32 ? 20 : 10 }); } catch (e) { console.error('Failed to validate TOTP:', e);