VYPR
Medium severity6.8NVD Advisory· Published May 20, 2026· Updated May 20, 2026

CVE-2026-35593

CVE-2026-35593

Description

Trilium Notes is an open-source, cross-platform hierarchical note taking application for building large personal knowledge bases. Versions 0.102.1 and prior are vulnerable to Local File Inclusion, allowing an authenticated attacker to read sensitive arbitrary files from the server's filesystem. The uploadModifiedFileToAttachment function, which is called when a POST request is received to /api/attachments/{attachmentId}/upload-modified-file, replaces the content of the attachment with the content from another file (whose path is provided in filePath of Request body). After which the content of the attachment can be viewed at /api/attachments/{attachmentId}/download. This exposes sensitive system files such as SSH keys, credentials, configs, and OS files, potentially leading to remote code execution and compromise of co-hosted applications. This issue has been fixed in version 0.102.2.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Trilium Notes versions ≤0.102.1 allow authenticated LFI via uploadModifiedFileToAttachment, exposing sensitive files; fixed in 0.102.2.

Vulnerability

Trilium Notes versions 0.102.1 and prior are vulnerable to Local File Inclusion (LFI) in the uploadModifiedFileToAttachment and uploadModifiedFileToNote functions. When a POST request is sent to /api/attachments/{attachmentId}/upload-modified-file, the endpoint replaces the attachment content with the content of an arbitrary file specified in the filePath parameter of the request body. This allows an authenticated attacker to read sensitive system files [1].

Exploitation

An attacker must be authenticated to the Trilium instance. The attacker uploads an attachment to obtain an attachmentId, then sends a POST request to /api/attachments/{attachmentId}/upload-modified-file with a JSON body containing filePath set to a target file (e.g., /etc/passwd). Subsequently, a GET request to /api/attachments/{attachmentId}/download retrieves the file contents [1].

Impact

Successful exploitation enables reading arbitrary files from the server filesystem, including SSH private keys, configuration files, credentials, and sensitive operating system files. This can lead to remote code execution if SSH keys are compromised, and may expose data from co-hosted applications [1].

Mitigation

The vulnerability is fixed in version 0.102.2 [2]. Users should upgrade immediately. No known workarounds are available, but applying strict input validation and path whitelisting as recommended in the advisory can reduce risk [1].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

6
8ff41d8fa924

fix(server): align attachment upload validation with note upload

https://github.com/triliumnext/triliumElian DoranApr 4, 2026Fixed in 0.102.2via llm-release-walk
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}'`);
    
90822cc8a372

chore: address requested changes

https://github.com/triliumnext/triliumElian DoranApr 5, 2026Fixed in 0.102.2via llm-release-walk
2 files changed · +36 35
  • apps/client/src/services/doc_renderer.ts+19 13 modified
    @@ -21,20 +21,26 @@ export function isValidDocName(docName: string): boolean {
     
     export default function renderDoc(note: FNote) {
         return new Promise<JQuery<HTMLElement>>((resolve) => {
    -        let docName = note.getLabelValue("docName");
    +        const docName = note.getLabelValue("docName");
             const $content = $("<div>");
     
    -        if (docName) {
    -            // find doc based on language
    -            const url = getUrl(docName, getCurrentLanguage());
    +        // find doc based on language
    +        const url = getUrl(docName, getCurrentLanguage());
    +
    +        if (url) {
                 $content.load(url, async (response, status) => {
                     // fallback to english doc if no translation available
                     if (status === "error") {
                         const fallbackUrl = getUrl(docName, "en");
    -                    $content.load(fallbackUrl, async () => {
    -                        await processContent(fallbackUrl, $content)
    +
    +                    if (fallbackUrl) {
    +                        $content.load(fallbackUrl, async () => {
    +                            await processContent(fallbackUrl, $content);
    +                            resolve($content);
    +                        });
    +                    } else {
                             resolve($content);
    -                    });
    +                    }
                         return;
                     }
     
    @@ -44,8 +50,6 @@ export default function renderDoc(note: FNote) {
             } else {
                 resolve($content);
             }
    -
    -        return $content;
         });
     }
     
    @@ -55,7 +59,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
         // Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
         $content.find("img").each((i, el) => {
             const $img = $(el);
    -        $img.attr("src", dir + "/" + $img.attr("src"));
    +        $img.attr("src", `${dir  }/${  $img.attr("src")}`);
         });
     
         formatCodeBlocks($content);
    @@ -64,15 +68,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
         await applyReferenceLinks($content[0]);
     }
     
    -function getUrl(docNameValue: string, language: string) {
    +function getUrl(docNameValue: string | null, language: string) {
    +    if (!docNameValue) return;
    +
         if (!isValidDocName(docNameValue)) {
             console.error(`Invalid docName: ${docNameValue}`);
    -        return "";
    +        return null;
         }
     
         // Cannot have spaces in the URL due to how JQuery.load works.
         docNameValue = docNameValue.replaceAll(" ", "%20");
     
    -    const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
    +    const basePath = window.glob.isDev ? `${window.glob.assetPath  }/..` : window.glob.assetPath;
         return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
     }
    
  • apps/server/src/services/open_id.ts+17 22 modified
    @@ -1,4 +1,3 @@
    -import crypto from "crypto";
     import type { NextFunction, Request, Response } from "express";
     import type { Session } from "express-openid-connect";
     
    @@ -60,34 +59,31 @@ function getOAuthStatus() {
         };
     }
     
    -function isTokenValid(req: Request, res: Response, next: NextFunction) {
    +async function isTokenValid(req: Request, res: Response, next: NextFunction) {
         const userStatus = openIDEncryption.isSubjectIdentifierSaved();
     
         if (req.oidc !== undefined) {
    -        const result = req.oidc
    -            .fetchUserInfo()
    -            .then((result) => {
    -                return {
    -                    success: true,
    -                    message: "Token is valid",
    -                    user: userStatus,
    -                };
    -            })
    -            .catch((result) => {
    -                return {
    -                    success: false,
    -                    message: "Token is not valid",
    -                    user: userStatus,
    -                };
    -            });
    -        return result;
    -    } 
    +        try {
    +            await req.oidc.fetchUserInfo();
    +            return {
    +                success: true,
    +                message: "Token is valid",
    +                user: userStatus,
    +            };
    +        } catch {
    +            return {
    +                success: false,
    +                message: "Token is not valid",
    +                user: userStatus,
    +            };
    +        }
    +    }
    +
         return {
             success: false,
             message: "Token not set up",
             user: userStatus,
         };
    -    
     }
     
     function getSSOIssuerName() {
    @@ -122,7 +118,6 @@ function generateOAuthConfig() {
                 scope: "openid profile email",
                 access_type: "offline",
                 prompt: "consent",
    -            state: crypto.randomUUID()
             },
             routes: authRoutes,
             idpLogout: true,
    
5c46209ddca3

feat(server): improve request handling for SVGs

https://github.com/triliumnext/triliumElian DoranApr 5, 2026Fixed in 0.102.2via llm-release-walk
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);
     }
    
7f199c527ba8

feat(share): improve request handling for SVGs

https://github.com/triliumnext/triliumElian DoranApr 5, 2026Fixed in 0.102.2via llm-release-walk
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);
     }
     
    
2432e230c5d4

chore(etapi): enforce MIME for image upload

https://github.com/triliumnext/triliumElian DoranApr 5, 2026Fixed in 0.102.2via llm-release-walk
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();
    
b3716754947b

chore(commons): mark docName as a dangerous attribute

https://github.com/triliumnext/triliumElian DoranApr 4, 2026Fixed in 0.102.2via llm-release-walk
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" },
    

Vulnerability mechanics

Root cause

"Missing validation that the provided file path refers to a temporary file, allowing an attacker to supply an arbitrary filesystem path."

Attack vector

An authenticated attacker sends a POST request to `/api/attachments/{attachmentId}/upload-modified-file` with a JSON body containing a `filePath` field pointing to an arbitrary file on the server (e.g., `/etc/shadow` or SSH private keys). The `uploadModifiedFileToAttachment` function reads the content of that file and stores it as the attachment's content. The attacker then downloads the attachment via `/api/attachments/{attachmentId}/download` to retrieve the sensitive file contents. No path traversal neutralization or temporary-file check is performed [CWE-22][CWE-73].

Affected code

The vulnerable function is `uploadModifiedFileToAttachment` in `apps/server/src/routes/api/files.ts`. It accepts a `filePath` from the request body and reads that file without verifying it is a temporary file created by the application.

What the fix does

The patch adds a guard that checks whether the supplied `filePath` exists in the `createdTemporaryFiles` set before proceeding with the file read [patch_id=805394]. If the path is not recognized as a temporary file, a `ValidationError` is thrown and the operation is aborted. This ensures that only files previously created by the application (e.g., uploaded or generated temporary files) can be used as the source for updating an attachment, closing the arbitrary file read vector.

Preconditions

  • authAttacker must be authenticated to the Trilium Notes application
  • networkAttacker must be able to send HTTP POST requests to the server
  • inputAttacker must supply a filePath pointing to an existing file on the server filesystem

Generated on May 20, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.