CVE-2026-45668
Description
Trilium Notes is a cross-platform, hierarchical note taking application focused on building large personal knowledge bases. Prior to 0.102.2, a malicious ZIP archive imported with safe import enabled achieves RCE via #docName path traversal and XSS by combining a payload note (type: code, mime: text/plain) containing raw HTML/JS and a trigger note (type: doc or type: launcher) with a #docName label that uses ../ path traversal to point at the payload note's API endpoint. The desktop client Electron renderer runs with nodeIntegration enabled, so an RCE is triggered once the payload is executed. This vulnerability is fixed in 0.102.2.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Trilium Notes prior to 0.102.2 allows RCE via safe import bypass using #docName path traversal and XSS in the desktop client.
Vulnerability
Trilium Notes before version 0.102.2 contains a vulnerability in the safe import feature. A malicious ZIP archive can achieve remote code execution (RCE) by combining a payload note (type: code, mime: text/plain) containing raw HTML/JS and a trigger note (type: doc or type: launcher) with a #docName label that uses ../ path traversal to point at the payload note's API endpoint. The desktop client Electron renderer runs with nodeIntegration enabled, allowing the payload to execute. [1]
Exploitation
An attacker must import a crafted ZIP archive with safe import enabled. The safe import does not sanitize non-HTML content types, so the raw HTML/JS payload is stored. The trigger note's #docName label uses path traversal (e.g., ../) to reference the payload note's API endpoint. When the trigger note is rendered, the URL is constructed without validation, leading to XSS. Because nodeIntegration is enabled, the XSS escalates to RCE. [1]
Impact
Successful exploitation grants the attacker remote code execution in the Electron renderer process. This allows full control over the desktop client, including access to the user's notes and system resources. [1]
Mitigation
The vulnerability is fixed in Trilium Notes version 0.102.2. Users should upgrade to this version immediately. No workarounds are available. The CVE is not listed on the Known Exploited Vulnerabilities (KEV) catalog. [1]
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2<0.102.2+ 1 more
- (no CPE)range: <0.102.2
- (no CPE)range: <0.102.2
Patches
9fc1be0d23db5fix(ckeditor5-mermaid): use textContent for diagram source rendering
1 file changed · +2 −2
packages/ckeditor5-mermaid/src/mermaidediting.ts+2 −2 modified@@ -183,7 +183,7 @@ export default class MermaidEditing extends Plugin { const mermaidSource = data.item.getAttribute( 'source' ) as string; const domElement = this.toDomElement( domDocument ); - domElement.innerHTML = mermaidSource; + domElement.textContent = mermaidSource; window.setTimeout( () => { // @todo: by the looks of it the domElement needs to be hooked to tree in order to allow for rendering. @@ -219,7 +219,7 @@ export default class MermaidEditing extends Plugin { const domPreviewWrapper = domConverter.viewToDom(child); if ( domPreviewWrapper ) { - domPreviewWrapper.innerHTML = newSource; + domPreviewWrapper.textContent = newSource; domPreviewWrapper.removeAttribute( 'data-processed' ); this._renderMermaid( domPreviewWrapper );
626aca518188fix(client): toasts could render HTML content
1 file changed · +1 −2
apps/client/src/widgets/Toast.tsx+1 −2 modified@@ -5,7 +5,6 @@ import { useEffect } from "preact/hooks"; import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast"; import Icon from "./react/Icon"; -import { RawHtmlBlock } from "./react/RawHtml"; import Button from "./react/Button"; export default function ToastContainer() { @@ -54,7 +53,7 @@ function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOp <div class="toast-icon">{toastIcon}</div> )} - <RawHtmlBlock className="toast-body" html={message} /> + <div className="toast-body">{message}</div> {!title && <div class="toast-header">{closeButton}</div>}
ed3b86cd49f6fix(import): no longer preserve named note IDs
1 file changed · +1 −2
apps/server/src/services/import/zip.ts+1 −2 modified@@ -51,8 +51,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu return "empty_note_id"; } - if (origNoteId === "root" || origNoteId.startsWith("_") || opts?.preserveIds) { - // these "named" noteIds don't differ between Trilium instances + if (origNoteId === "root" || opts?.preserveIds) { return origNoteId; }
b3716754947bchore(commons): mark docName as a dangerous attribute
1 file changed · +1 −0
packages/commons/src/lib/builtin_attributes.ts+1 −0 modified@@ -81,6 +81,7 @@ export default [ { type: "label", name: "webViewSrc", isDangerous: true }, { type: "label", name: "hideHighlightWidget" }, { type: "label", name: "iconPack", isDangerous: true }, + { type: "label", name: "docName", isDangerous: true }, { type: "label", name: "printLandscape" }, { type: "label", name: "printPageSize" },
ff06c8e7bd42fix(client): validate docName attribute in doc renderer
2 files changed · +51 −0
apps/client/src/services/doc_renderer.spec.ts+30 −0 added@@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { isValidDocName } from "./doc_renderer.js"; + +describe("isValidDocName", () => { + it("accepts valid docNames", () => { + expect(isValidDocName("launchbar_intro")).toBe(true); + expect(isValidDocName("User Guide/Quick Start")).toBe(true); + expect(isValidDocName("User Guide/User Guide/Quick Start")).toBe(true); + expect(isValidDocName("Quick Start Guide")).toBe(true); + expect(isValidDocName("quick_start_guide")).toBe(true); + expect(isValidDocName("quick-start-guide")).toBe(true); + }); + + it("rejects path traversal attacks", () => { + expect(isValidDocName("..")).toBe(false); + expect(isValidDocName("../etc/passwd")).toBe(false); + expect(isValidDocName("foo/../bar")).toBe(false); + expect(isValidDocName("../../../../api/notes/_malicious/open")).toBe(false); + expect(isValidDocName("..\\etc\\passwd")).toBe(false); + expect(isValidDocName("foo\\bar")).toBe(false); + }); + + it("rejects URL manipulation attacks", () => { + expect(isValidDocName("../../../../api/notes/_malicious/open?x=")).toBe(false); + expect(isValidDocName("foo#bar")).toBe(false); + expect(isValidDocName("%2e%2e")).toBe(false); + expect(isValidDocName("%2e%2e%2f%2e%2e%2fapi")).toBe(false); + }); +});
apps/client/src/services/doc_renderer.ts+21 −0 modified@@ -3,6 +3,22 @@ import { applyReferenceLinks } from "../widgets/type_widgets/text/read_only_help import { getCurrentLanguage } from "./i18n.js"; import { formatCodeBlocks } from "./syntax_highlight.js"; +/** + * Validates a docName to prevent path traversal attacks. + * Allows forward slashes for subdirectories (e.g., "User Guide/Quick Start") + * but blocks traversal sequences and URL manipulation characters. + */ +export function isValidDocName(docName: string): boolean { + if (docName.includes("..") || + docName.includes("\\") || + docName.includes("?") || + docName.includes("#") || + docName.includes("%")) { + return false; + } + return true; +} + export default function renderDoc(note: FNote) { return new Promise<JQuery<HTMLElement>>((resolve) => { let docName = note.getLabelValue("docName"); @@ -49,6 +65,11 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) { } function getUrl(docNameValue: string, language: string) { + if (!isValidDocName(docNameValue)) { + console.error(`Invalid docName: ${docNameValue}`); + return ""; + } + // Cannot have spaces in the URL due to how JQuery.load works. docNameValue = docNameValue.replaceAll(" ", "%20");
5c46209ddca3feat(server): improve request handling for SVGs
3 files changed · +46 −27
apps/server/src/routes/api/image.ts+28 −10 modified@@ -1,12 +1,14 @@ -"use strict"; -import imageService from "../../services/image.js"; -import becca from "../../becca/becca.js"; -import fs from "fs"; + import type { Request, Response } from "express"; +import fs from "fs"; + +import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; +import imageService from "../../services/image.js"; import { RESOURCE_DIR } from "../../services/resource_dir.js"; +import { sanitizeSvg } from "../../services/utils.js"; function returnImageFromNote(req: Request, res: Response) { const image = becca.getNote(req.params.noteId); @@ -37,28 +39,33 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { } else { res.set("Content-Type", image.mime); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); - res.send(image.getContent()); + + if (image.mime === "image/svg+xml") { + sendSanitizedSvg(res, image.getContent()); + } else { + res.send(image.getContent()); + } } } export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) { - let svg: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`; + let svgContent: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`; const attachment = image.getAttachmentByTitle(attachmentName); if (attachment) { - svg = attachment.getContent(); + svgContent = attachment.getContent(); } else { // backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key const contentSvg = image.getJsonContentSafely()?.svg; if (contentSvg) { - svg = contentSvg; + svgContent = contentSvg; } } res.set("Content-Type", "image/svg+xml"); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); - res.send(svg); + sendSanitizedSvg(res, svgContent); } function returnAttachedImage(req: Request, res: Response) { @@ -75,7 +82,12 @@ function returnAttachedImage(req: Request, res: Response) { res.set("Content-Type", attachment.mime); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); - res.send(attachment.getContent()); + + if (attachment.mime === "image/svg+xml") { + sendSanitizedSvg(res, attachment.getContent()); + } else { + res.send(attachment.getContent()); + } } function updateImage(req: Request) { @@ -116,3 +128,9 @@ export default { returnAttachedImage, updateImage }; + +function sendSanitizedSvg(res: Response, content: string | Buffer) { + const svgString = typeof content === "string" ? content : content.toString("utf-8"); + res.set("Content-Security-Policy", "script-src 'none'"); + res.send(sanitizeSvg(svgString)); +}
apps/server/src/services/utils.ts+17 −0 modified@@ -119,6 +119,22 @@ export function sanitizeSqlIdentifier(str: string) { return str.replace(/[^A-Za-z0-9_]/g, ""); } +/** + * Sanitize SVG to remove potentially dangerous elements and attributes. + * This prevents XSS via script injection in SVG content. + */ +export function sanitizeSvg(svg: string): string { + return svg + // Remove script elements + .replace(/<script[\s\S]*?<\/script>/gi, '') + // Remove on* event handlers (onclick, onload, onerror, etc.) + .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '') + .replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '') + // Remove javascript: URLs + .replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"') + .replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"'); +} + export const escapeHtml = escape; export const unescapeHtml = unescape; @@ -556,6 +572,7 @@ export default { replaceAll, safeExtractMessageAndStackFromError, sanitizeSqlIdentifier, + sanitizeSvg, stripTags, slugify, timeLimit,
apps/server/src/share/routes.ts+1 −17 modified@@ -9,30 +9,14 @@ import SearchContext from "../services/search/search_context.js"; import type SNote from "./shaca/entities/snote.js"; import type SAttachment from "./shaca/entities/sattachment.js"; import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js"; -import utils from "../services/utils.js"; +import utils, { sanitizeSvg } from "../services/utils.js"; function addNoIndexHeader(note: SNote, res: Response) { if (note.isLabelTruthy("shareDisallowRobotIndexing")) { res.setHeader("X-Robots-Tag", "noindex"); } } -/** - * Sanitize SVG to remove potentially dangerous elements and attributes. - * This prevents XSS via script injection in SVG exports. - */ -function sanitizeSvg(svg: string): string { - return svg - // Remove script elements - .replace(/<script[\s\S]*?<\/script>/gi, '') - // Remove on* event handlers (onclick, onload, onerror, etc.) - .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '') - .replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '') - // Remove javascript: URLs - .replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"') - .replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"'); -} - function requestCredentials(res: Response) { res.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"').sendStatus(401); }
7f199c527ba8feat(share): improve request handling for SVGs
1 file changed · +18 −1
apps/server/src/share/routes.ts+18 −1 modified@@ -17,6 +17,22 @@ function addNoIndexHeader(note: SNote, res: Response) { } } +/** + * Sanitize SVG to remove potentially dangerous elements and attributes. + * This prevents XSS via script injection in SVG exports. + */ +function sanitizeSvg(svg: string): string { + return svg + // Remove script elements + .replace(/<script[\s\S]*?<\/script>/gi, '') + // Remove on* event handlers (onclick, onload, onerror, etc.) + .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '') + .replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '') + // Remove javascript: URLs + .replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"') + .replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"'); +} + function requestCredentials(res: Response) { res.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"').sendStatus(401); } @@ -102,9 +118,10 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri } } - const svg = svgString; + const svg = sanitizeSvg(svgString); res.set("Content-Type", "image/svg+xml"); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); + res.set("Content-Security-Policy", "script-src 'none'"); res.send(svg); }
2432e230c5d4chore(etapi): enforce MIME for image upload
1 file changed · +12 −0
apps/server/src/etapi/notes.ts+12 −0 modified@@ -66,6 +66,11 @@ function register(router: Router) { eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_NOTE); const params = _params as NoteParams; + // Validate MIME type for image notes + if (params.type === "image" && params.mime && !params.mime.startsWith("image/")) { + throw new eu.EtapiError(400, "INVALID_MIME_FOR_IMAGE", `MIME type '${params.mime}' is not allowed for image notes. MIME must start with 'image/'.`); + } + try { const resp = noteService.createNewNote(params); @@ -93,6 +98,13 @@ function register(router: Router) { throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`); } + // Validate MIME type for image notes (check both current and new type/mime) + const effectiveType = req.body.type || note.type; + const effectiveMime = req.body.mime || note.mime; + if (effectiveType === "image" && effectiveMime && !effectiveMime.startsWith("image/")) { + throw new eu.EtapiError(400, "INVALID_MIME_FOR_IMAGE", `MIME type '${effectiveMime}' is not allowed for image notes. MIME must start with 'image/'.`); + } + noteService.saveRevisionIfNeeded(note); eu.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH); note.save();
8ff41d8fa924fix(server): align attachment upload validation with note upload
1 file changed · +4 −0
apps/server/src/routes/api/files.ts+4 −0 modified@@ -232,6 +232,10 @@ function uploadModifiedFileToAttachment(req: Request) { const { attachmentId } = req.params; const { filePath } = req.body; + if (!createdTemporaryFiles.has(filePath)) { + throw new ValidationError(`File '${filePath}' is not a temporary file.`); + } + const attachment = becca.getAttachmentOrThrow(attachmentId); log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);
Vulnerability mechanics
Root cause
"Missing input validation on the docName label value allows path traversal out of the static doc_notes directory, combined with jQuery's .load() parsing arbitrary responses as HTML, leading to XSS and RCE in the Electron renderer."
Attack vector
An attacker crafts a malicious ZIP archive containing two notes: a payload note (type: code, mime: text/plain) with raw HTML/JS, and a trigger note (type: doc or launcher) with a `#docName` label containing `../` path traversal sequences pointing to the payload note's API endpoint. When a victim imports the archive with safe import enabled, the doc renderer constructs a URL like `../../../../api/notes/_payload/open?x=` which traverses out of the static doc_notes directory and hits the `/api/notes/:noteId/open` route. jQuery's `.load()` fetches the response and parses it as HTML regardless of Content-Type, inserting the payload into the DOM. Because the Electron renderer has `nodeIntegration` enabled, the injected HTML/JS achieves remote code execution [ref_id=1].
Affected code
The vulnerability resides in `apps/client/src/services/doc_renderer.ts` where the `getUrl()` function concatenates the `docName` label value into a URL without validation, and in `apps/server/src/services/builtin_attributes.ts` where `docName` was not marked as a dangerous attribute. The fix adds `isValidDocName()` validation in the doc renderer and marks `docName` as dangerous in the builtin attributes list [patch_id=3104636][patch_id=3104637].
What the fix does
The fix adds an `isValidDocName()` function in `apps/client/src/services/doc_renderer.ts` that rejects docName values containing `..`, backslashes, `?`, `#`, or `%` characters, preventing path traversal and URL manipulation [patch_id=3104637]. Additionally, `docName` is marked as `isDangerous: true` in the builtin attributes list, so safe import will disable the attribute on imported notes [patch_id=3104636]. A separate commit removes the `RawHtmlBlock` component from toast rendering to prevent HTML injection in toast messages [patch_id=3104635].
Preconditions
- inputVictim must import a malicious ZIP archive into Trilium Notes
- configSafe import must be enabled (the vulnerability bypasses its sanitization)
- configDesktop client Electron renderer must have nodeIntegration enabled (default)
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.