CVE-2026-39311
Description
Trilium Notes is a cross-platform, hierarchical note taking application focused on building large personal knowledge bases. Versions 0.102.1 and prior contain a critical security flaw where lack of SVG sanitization combined with a disabled Content Security Policy (CSP) and a publicly reachable backend execution API results in an unauthenticated Remote Code Execution (RCE). The vulnerability arises from an insecure-by-design architecture: Trilium serves SVG attachments with the image/svg+xml MIME type without any sanitization, and it explicitly disables Helmet's Content Security Policy middleware, removing the primary defense against script execution in served assets. Because the malicious SVG runs under the Same-Origin Policy, it can issue a fetch('/') to extract the csrfToken from the document body. With that token, it can send a signed request to /api/script/exec to execute arbitrary Node.js code on the server. An attacker can compromise the entire server instance simply by tricking an authenticated user into viewing a shared SVG attachment. The 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 unauthenticated RCE via unsanitized SVGs due to disabled CSP, enabling CSRF token theft and arbitrary code execution on the server.
Vulnerability
Trilium Notes is a cross-platform, hierarchical note-taking application. Versions 0.102.1 and prior contain a critical security flaw where SVG attachments are served with the image/svg+xml MIME type without any sanitization [1]. The application explicitly disables Helmet's Content Security Policy middleware [1]. This combination allows an unauthenticated attacker to achieve Remote Code Execution (RCE) by hosting a malicious SVG attachment that, when viewed by an authenticated user, executes arbitrary JavaScript in the context of the victim's session. The issue is fixed in version 0.102.2 [2].
Exploitation
An attacker must first create a malicious SVG file containing a ` tag that executes JavaScript. The victim must be an authenticated user who is tricked into viewing the SVG attachment within Trilium (e.g., through a shared note link). Because the SVG is served under the Same-Origin Policy, the embedded script can issue a fetch('/') to retrieve the HTML of the main application page, parse the csrfToken from the document body, and then send a signed POST request to /api/script/exec` [1]. The attacker does not require authentication or any special network position beyond the ability to deliver the SVG to a victim.
Impact
Upon successful exploitation, the attacker can execute arbitrary Node.js code on the Trilium server with the full privileges of the application process. This leads to a complete compromise of the server instance, including potential data exfiltration, modification, or destruction of all notes and attachments. The confidentiality, integrity, and availability of the entire knowledge base and underlying system are at risk [1].
Mitigation
The vulnerability has been fixed in Trilium version 0.102.2 [2]. Users on version 0.102.1 or earlier are strongly urged to upgrade immediately. No workarounds have been provided as the fix involves server-side changes to sanitize SVG content and enforce Content Security Policy. This CVE is not listed on CISA's Known Exploited Vulnerabilities (KEV) catalog as of the publication date.
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(expand)+ 1 more
- (no CPE)
- (no CPE)range: <=0.102.1
Patches
89a4fef80b954chore(deps): fix pnpm lock
1 file changed · +10 −10
pnpm-lock.yaml+10 −10 modified@@ -447,10 +447,10 @@ importers: version: 7.11.1 '@electron-forge/plugin-fuses': specifier: 7.11.1 - version: 7.11.1(@electron/fuses@2.1.1) + version: 7.11.1(@electron/fuses@1.0.0) '@electron/fuses': - specifier: 2.1.1 - version: 2.1.1 + specifier: 1.0.0 + version: 1.0.0 '@triliumnext/commons': specifier: workspace:* version: link:../../packages/commons @@ -2394,10 +2394,8 @@ packages: engines: {node: '>=10.12.0'} hasBin: true - '@electron/fuses@2.1.1': - resolution: {integrity: sha512-38ho27/mtUV/LpsZ1LCDJUomKBBSUZDk/qBH4FNNtoN5fmnkmWDcIp5pm1Kv3InqhRjKZKs7Jzx+wWZNMArHrA==} - engines: {node: '>=22.12.0'} - hasBin: true + '@electron/fuses@1.0.0': + resolution: {integrity: sha512-VjWIlZHEB7a93tXl+6tX2YzN+s1/mS0RM8WX4GZlMOqAzlmRfTMP6pp0MM0LtkzWZB+KQOv+zJt5Dlgdik+DUQ==} '@electron/get@2.0.3': resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} @@ -17620,11 +17618,11 @@ snapshots: - bluebird - supports-color - '@electron-forge/plugin-fuses@7.11.1(@electron/fuses@2.1.1)': + '@electron-forge/plugin-fuses@7.11.1(@electron/fuses@1.0.0)': dependencies: '@electron-forge/plugin-base': 7.11.1 '@electron-forge/shared-types': 7.11.1 - '@electron/fuses': 2.1.1 + '@electron/fuses': 1.0.0 transitivePeerDependencies: - bluebird - supports-color @@ -17711,7 +17709,9 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 - '@electron/fuses@2.1.1': {} + '@electron/fuses@1.0.0': + dependencies: + fs-extra: 9.1.0 '@electron/get@2.0.3': dependencies:
9bc18b774e39test(server): add unit tests for sanitizeSvg
1 file changed · +107 −0
apps/server/src/services/utils.spec.ts+107 −0 modified@@ -705,3 +705,110 @@ describe("#slugify", () => { expect(result).toBe(expectedSlug); }); }); + +describe("#sanitizeSvg", () => { + it("should remove script elements", () => { + const maliciousSvg = '<svg><script>alert("XSS")</script><rect width="100" height="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100" height="100"/></svg>'); + }); + + it("should remove script elements with attributes", () => { + const maliciousSvg = '<svg><script type="text/javascript">alert("XSS")</script></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg></svg>'); + }); + + it("should remove multiline script elements", () => { + const maliciousSvg = `<svg><script> + var x = 1; + alert(x); + </script></svg>`; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg></svg>'); + }); + + it("should remove onclick event handlers with double quotes", () => { + const maliciousSvg = '<svg><rect onclick="doEvil()" width="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); + + it("should remove onclick event handlers with single quotes", () => { + const maliciousSvg = "<svg><rect onclick='doEvil()' width=\"100\"/></svg>"; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); + + it("should remove onload event handlers", () => { + const maliciousSvg = '<svg onload="doEvil()"><rect width="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); + + it("should remove onerror event handlers", () => { + const maliciousSvg = '<svg><image onerror="alert(1)" href="invalid.jpg"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><image href="invalid.jpg"/></svg>'); + }); + + it("should remove onmouseover event handlers", () => { + const maliciousSvg = '<svg><rect onmouseover="alert(1)" width="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); + + it("should remove event handlers without quotes", () => { + const maliciousSvg = '<svg><rect onclick=alert(1) width="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); + + it("should replace javascript: URLs in href with #", () => { + const maliciousSvg = '<svg><a href="javascript:alert(1)"><text>Click me</text></a></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><a href="#"><text>Click me</text></a></svg>'); + }); + + it("should replace javascript: URLs in xlink:href with #", () => { + const maliciousSvg = '<svg><a xlink:href="javascript:alert(1)"><text>Click me</text></a></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><a xlink:href="#"><text>Click me</text></a></svg>'); + }); + + it("should preserve valid SVG content", () => { + const validSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="10" y="10" width="80" height="80" fill="blue"/><circle cx="50" cy="50" r="30" fill="red"/></svg>'; + const result = utils.sanitizeSvg(validSvg); + expect(result).toBe(validSvg); + }); + + it("should preserve valid href URLs", () => { + const validSvg = '<svg><a href="https://example.com"><text>Link</text></a></svg>'; + const result = utils.sanitizeSvg(validSvg); + expect(result).toBe(validSvg); + }); + + it("should handle multiple malicious elements", () => { + const maliciousSvg = '<svg onload="evil()"><script>evil()</script><rect onclick="bad()" width="100"/><a href="javascript:attack()">link</a></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/><a href="#">link</a></svg>'); + }); + + it("should handle empty SVG", () => { + const emptySvg = '<svg></svg>'; + const result = utils.sanitizeSvg(emptySvg); + expect(result).toBe('<svg></svg>'); + }); + + it("should be case insensitive for script tags", () => { + const maliciousSvg = '<svg><SCRIPT>alert(1)</SCRIPT><Script>alert(2)</Script></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg></svg>'); + }); + + it("should be case insensitive for event handlers", () => { + const maliciousSvg = '<svg><rect ONCLICK="alert(1)" width="100"/></svg>'; + const result = utils.sanitizeSvg(maliciousSvg); + expect(result).toBe('<svg><rect width="100"/></svg>'); + }); +});
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); }
79dc4b39f18achore(client): address requested changes
1 file changed · +4 −9
apps/client/src/services/doc_renderer.ts+4 −9 modified@@ -9,14 +9,9 @@ import { formatCodeBlocks } from "./syntax_highlight.js"; * 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; + // Allow alphanumeric characters, spaces, underscores, hyphens, and forward slashes. + const validDocNameRegex = /^[a-zA-Z0-9_/\- ]+$/; + return validDocNameRegex.test(docName); } export default function renderDoc(note: FNote) { @@ -59,7 +54,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);
90822cc8a372chore: address requested changes
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,
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
"SVG content is served with the image/svg+xml MIME type without sanitization, and the Content Security Policy is disabled, allowing injected scripts to execute under the Same-Origin Policy and call the backend execution API."
Attack vector
An attacker crafts a malicious SVG file containing embedded JavaScript (e.g., via <script> tags or event handlers like onload). The SVG is served with the image/svg+xml MIME type and no Content Security Policy, so the script executes in the victim's browser under the Same-Origin Policy. The script can then fetch('/') to extract the csrfToken from the HTML body and use it to send a signed POST request to /api/script/exec, achieving arbitrary Node.js code execution on the server. The attack requires tricking an authenticated user into viewing a shared SVG attachment.
Affected code
The vulnerability affects SVG serving in apps/server/src/routes/api/image.ts (returnImageInt, renderSvgAttachment, returnAttachedImage) and apps/server/src/share/routes.ts (renderImageAttachment). These functions served SVG content with image/svg+xml MIME type without sanitization and without a Content-Security-Policy header. The sanitizeSvg function was originally defined inline in the share routes file and was not applied to the main API routes.
What the fix does
The patches introduce a centralized sanitizeSvg() function [patch_id=877096][patch_id=877097] that strips <script> elements, removes on* event handler attributes, and replaces javascript: URLs with "#". This function is called before serving any SVG content in both the main API routes (apps/server/src/routes/api/image.ts) and the share routes (apps/server/src/share/routes.ts). Additionally, a Content-Security-Policy header of "script-src 'none'" is set on SVG responses [patch_id=877096][patch_id=877097], providing a defense-in-depth layer. The ETAPI upload validation [patch_id=877422] and attachment upload validation [patch_id=877424] prevent non-image MIME types from being stored as image notes, closing an alternative vector for injecting malicious content.
Preconditions
- socialAttacker must trick an authenticated user into viewing a shared SVG attachment
- networkThe server must have the backend execution API (/api/script/exec) reachable
- configContent Security Policy must be disabled (Helmet CSP middleware explicitly disabled by design)
Generated on May 20, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.