diff --git a/apps/client/src/services/content_renderer_text.ts b/apps/client/src/services/content_renderer_text.ts index 9317d0b6e0d..04be8252985 100644 --- a/apps/client/src/services/content_renderer_text.ts +++ b/apps/client/src/services/content_renderer_text.ts @@ -5,6 +5,7 @@ import froca from "./froca.js"; import link from "./link.js"; import { renderMathInElement } from "./math.js"; import { getMermaidConfig } from "./mermaid.js"; +import { sanitizeNoteContentHtml } from "./sanitize_content.js"; import { formatCodeBlocks } from "./syntax_highlight.js"; import tree from "./tree.js"; import { isHtmlEmpty } from "./utils.js"; @@ -14,7 +15,7 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon const blob = await note.getBlob(); if (blob && !isHtmlEmpty(blob.content)) { - $renderedContent.append($('
').html(blob.content)); + $renderedContent.append($('
').html(sanitizeNoteContentHtml(blob.content))); const seenNoteIds = options.seenNoteIds ?? new Set(); seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId); diff --git a/apps/client/src/services/note_tooltip.ts b/apps/client/src/services/note_tooltip.ts index 23263966e27..c868781952b 100644 --- a/apps/client/src/services/note_tooltip.ts +++ b/apps/client/src/services/note_tooltip.ts @@ -5,6 +5,7 @@ import contentRenderer from "./content_renderer.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; import linkService from "./link.js"; +import { sanitizeNoteContentHtml } from "./sanitize_content.js"; import treeService from "./tree.js"; import utils from "./utils.js"; @@ -92,8 +93,9 @@ async function mouseEnterHandler(this: HTMLElement, e: JQuery.TriggeredEvent< return; } - const html = `
${content}
`; - const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`; + const sanitizedContent = sanitizeNoteContentHtml(content); + const html = `
${sanitizedContent}
`; + const tooltipClass = `tooltip-${Math.floor(Math.random() * 999_999_999)}`; // we need to check if we're still hovering over the element // since the operation to get tooltip content was async, it is possible that @@ -110,6 +112,8 @@ async function mouseEnterHandler(this: HTMLElement, e: JQuery.TriggeredEvent< title: html, html: true, template: ``, + // Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer + // (which is too aggressive for our rich-text content) can be disabled. sanitize: false, customClass: linkId }); diff --git a/apps/client/src/services/sanitize_content.spec.ts b/apps/client/src/services/sanitize_content.spec.ts new file mode 100644 index 00000000000..3573ba4ccc9 --- /dev/null +++ b/apps/client/src/services/sanitize_content.spec.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeNoteContentHtml } from "./sanitize_content"; + +describe("sanitizeNoteContentHtml", () => { + // --- Preserves legitimate CKEditor content --- + + it("preserves basic rich text formatting", () => { + const html = '

Bold and italic text

'; + expect(sanitizeNoteContentHtml(html)).toBe(html); + }); + + it("preserves headings", () => { + const html = '

Title

Subtitle

Section

'; + expect(sanitizeNoteContentHtml(html)).toBe(html); + }); + + it("preserves links with href", () => { + const html = 'Link'; + expect(sanitizeNoteContentHtml(html)).toBe(html); + }); + + it("preserves internal note links with data attributes", () => { + const html = 'My Note'; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain('class="reference-link"'); + expect(result).toContain('href="#root/abc123"'); + expect(result).toContain('data-note-path="root/abc123"'); + expect(result).toContain(">My Note"); + }); + + it("preserves images with src", () => { + const html = 'test'; + expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"'); + }); + + it("preserves tables", () => { + const html = '
Header
Cell
'; + expect(sanitizeNoteContentHtml(html)).toBe(html); + }); + + it("preserves code blocks", () => { + const html = '
const x = 1;
'; + expect(sanitizeNoteContentHtml(html)).toBe(html); + }); + + it("preserves include-note sections with data-note-id", () => { + const html = '
 
'; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain('class="include-note"'); + expect(result).toContain('data-note-id="abc123"'); + expect(result).toContain(" "); + }); + + it("preserves figure and figcaption", () => { + const html = '
Caption
'; + expect(sanitizeNoteContentHtml(html)).toContain("
"); + expect(sanitizeNoteContentHtml(html)).toContain("
"); + }); + + it("preserves task list checkboxes", () => { + const html = '
  • Task done
'; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain('type="checkbox"'); + expect(result).toContain("checked"); + }); + + it("preserves inline styles for colors", () => { + const html = 'Red text'; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain("style"); + expect(result).toContain("color"); + }); + + it("preserves data-* attributes", () => { + const html = '
Content
'; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain('data-custom-attr="value"'); + expect(result).toContain('data-note-id="abc"'); + }); + + // --- Blocks XSS vectors --- + + it("strips script tags", () => { + const html = '

Hello

World

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("Hello

"); + expect(result).toContain("

World

"); + }); + + it("strips onerror event handlers on images", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onerror"); + expect(result).not.toContain("alert"); + }); + + it("strips onclick event handlers", () => { + const html = '
Click me
'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onclick"); + expect(result).not.toContain("alert"); + }); + + it("strips onload event handlers", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onload"); + expect(result).not.toContain("alert"); + }); + + it("strips onmouseover event handlers", () => { + const html = 'Hover'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onmouseover"); + expect(result).not.toContain("alert"); + }); + + it("strips onfocus event handlers", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onfocus"); + expect(result).not.toContain("alert"); + }); + + it("strips javascript: URIs in href", () => { + const html = 'Click'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("javascript:"); + }); + + it("strips javascript: URIs in img src", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("javascript:"); + }); + + it("strips iframe tags", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = '

Text

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("Text

"); + }); + + it("strips SVG with embedded script", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = '

Text

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = '

Text

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + const html = '

Text

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" { + expect(sanitizeNoteContentHtml("")).toBe(""); + }); + + it("handles null-like falsy values", () => { + expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null); + expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined); + }); + + it("handles nested XSS attempts", () => { + const html = '

Safe

Also safe

'; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain("onerror"); + expect(result).not.toContain("fetch"); + expect(result).not.toContain("cookie"); + expect(result).toContain("Safe"); + expect(result).toContain("Also safe"); + }); + + it("handles case-varied event handlers", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result.toLowerCase()).not.toContain("onerror"); + }); + + it("strips dangerous data: URI on anchor elements", () => { + const html = 'Click'; + const result = sanitizeNoteContentHtml(html); + // DOMPurify should either strip the href or remove the dangerous content + expect(result).not.toContain(" { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).toContain("data:image/png"); + }); + + it("strips template tags which could contain scripts", () => { + const html = ''; + const result = sanitizeNoteContentHtml(html); + expect(result).not.toContain(" elements. +const ALLOWED_URI_REGEXP = /^(?:(?:https?|ftps?|mailto|evernote|file|gemini|git|gopher|irc|irc6|jabber|magnet|sftp|skype|sms|spotify|steam|svn|tel|smb|zotero|geo|obsidian|logseq|onenote|slack):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; + +/** + * DOMPurify configuration for sanitizing note content. + * + * Uses DOMPurify's built-in security-researched profiles for HTML, SVG, and + * MathML rather than a hand-maintained tag allowlist. This ensures proper + * namespace handling (critical for SVG rendering in mermaid/canvas/mind-map + * notes and MathML in KaTeX equations) while staying current with DOMPurify's + * upstream security fixes. + * + * Defense-in-depth is provided via FORBID_TAGS / FORBID_ATTR which explicitly + * block known-dangerous elements and all event-handler attributes, regardless + * of what the profiles permit. + */ +const PURIFY_CONFIG: DOMPurifyConfig = { + // Enable DOMPurify's curated safe-element sets for HTML, SVG, and MathML. + // This replaces a manual ALLOWED_TAGS list and correctly handles namespace + // parsing (e.g. SVG elements must be in the SVG namespace to render). + USE_PROFILES: { html: true, svg: true, svgFilters: true, mathMl: true }, + ALLOWED_URI_REGEXP, + // CKEditor data-* attributes not in the default set + ADD_ATTR: ["data-note-id", "data-note-path", "data-href", "data-language", + "data-value", "data-box-type", "data-link-id", "data-no-context-menu"], + // CKEditor custom elements + ADD_TAGS: ["en-media"], + // ── Explicit deny-lists (defense-in-depth) ── + // Script execution vectors + FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "link", "meta", + "base", "noscript", "template", + // SVG elements that can execute scripts or embed arbitrary HTML + "foreignObject", + // SVG animation elements — can trigger event handlers via + // onbegin/onend/onrepeat attributes + "animate", "animateMotion", "animateTransform", "set"], + // All DOM event-handler attributes + FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus", + "onblur", "onsubmit", "onreset", "onchange", "oninput", + "onkeydown", "onkeyup", "onkeypress", "onmousedown", + "onmouseup", "onmousemove", "onmouseout", "onmouseenter", + "onmouseleave", "ondblclick", "oncontextmenu", "onwheel", + "ondrag", "ondragend", "ondragenter", "ondragleave", + "ondragover", "ondragstart", "ondrop", "onscroll", + "oncopy", "oncut", "onpaste", "onanimationend", + "onanimationiteration", "onanimationstart", + "ontransitionend", "onpointerdown", "onpointerup", + "onpointermove", "onpointerover", "onpointerout", + "onpointerenter", "onpointerleave", "ontouchstart", + "ontouchend", "ontouchmove", "ontouchcancel", + // SVG animation event handlers + "onbegin", "onend", "onrepeat"], + // Allow data: URIs only for images (needed for inline images) + ADD_DATA_URI_TAGS: ["img"], + RETURN_DOM: false, + RETURN_DOM_FRAGMENT: false, + WHOLE_DOCUMENT: false +}; + +// Configure a DOMPurify hook to handle data-* attributes more broadly +// since CKEditor uses many custom data attributes. +DOMPurify.addHook("uponSanitizeAttribute", (node, data) => { + // Allow all data-* attributes + if (data.attrName.startsWith("data-")) { + data.forceKeepAttr = true; + } +}); + +/** + * Sanitizes HTML content for safe rendering in the DOM. + * + * This function should be called on all user-provided HTML content before + * inserting it into the DOM via dangerouslySetInnerHTML, jQuery .html(), + * or Element.innerHTML. + * + * The sanitizer preserves rich-text formatting produced by CKEditor + * (bold, italic, links, tables, images, code blocks, etc.) while + * stripping XSS vectors (script tags, event handlers, javascript: URIs). + * + * @param dirtyHtml - The untrusted HTML string to sanitize. + * @returns A sanitized HTML string safe for DOM insertion. + */ +export function sanitizeNoteContentHtml(dirtyHtml: string): string { + if (!dirtyHtml) { + return dirtyHtml; + } + return DOMPurify.sanitize(dirtyHtml, PURIFY_CONFIG) as string; +} + +export default { + sanitizeNoteContentHtml +}; diff --git a/apps/client/src/widgets/collections/legacy/ListPrintView.tsx b/apps/client/src/widgets/collections/legacy/ListPrintView.tsx index c2a284e7de4..57646d947b8 100644 --- a/apps/client/src/widgets/collections/legacy/ListPrintView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListPrintView.tsx @@ -4,6 +4,7 @@ import type FNote from "../../../entities/fnote"; import type { PrintReport } from "../../../print"; import content_renderer from "../../../services/content_renderer"; import froca from "../../../services/froca"; +import { sanitizeNoteContentHtml } from "../../../services/sanitize_content"; import type { ViewModeProps } from "../interface"; import { filterChildNotes, useFilteredNoteIds } from "./utils"; @@ -87,7 +88,7 @@ export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onPro

{note.title}

{state.notesWithContent?.map(({ note: childNote, contentEl }) => ( -
+
))}
diff --git a/apps/client/src/widgets/collections/presentation/model.ts b/apps/client/src/widgets/collections/presentation/model.ts index f1c6cef7794..8bb8831651a 100644 --- a/apps/client/src/widgets/collections/presentation/model.ts +++ b/apps/client/src/widgets/collections/presentation/model.ts @@ -1,6 +1,7 @@ import { NoteType } from "@triliumnext/commons"; import FNote from "../../../entities/fnote"; import contentRenderer from "../../../services/content_renderer"; +import { sanitizeNoteContentHtml } from "../../../services/sanitize_content"; import { ProgressChangedFn } from "../interface"; type DangerouslySetInnerHTML = { __html: string; }; @@ -72,7 +73,7 @@ async function processContent(note: FNote): Promise { const { $renderedContent } = await contentRenderer.getRenderedContent(note, { noChildrenList: true }); - return { __html: $renderedContent.html() }; + return { __html: sanitizeNoteContentHtml($renderedContent.html()) }; } async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) { diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 66ce763a3da..1772f60f1ac 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -14,6 +14,7 @@ import { t } from "../../services/i18n"; import { renderMathInElement } from "../../services/math"; import open from "../../services/open"; import options from "../../services/options"; +import { sanitizeNoteContentHtml } from "../../services/sanitize_content.js"; import protected_session_holder from "../../services/protected_session_holder"; import server from "../../services/server"; import toast from "../../services/toast"; @@ -291,7 +292,7 @@ function RevisionContentText({ content }: { content: string | Buffer; + return ; } function RevisionContentDiff({ noteContent, itemContent, itemType }: { diff --git a/apps/client/src/widgets/highlights_list.ts b/apps/client/src/widgets/highlights_list.ts index 512e8eff305..e989957563e 100644 --- a/apps/client/src/widgets/highlights_list.ts +++ b/apps/client/src/widgets/highlights_list.ts @@ -9,9 +9,39 @@ import appContext, { type EventData } from "../components/app_context.js"; import type FNote from "../entities/fnote.js"; import attributeService from "../services/attributes.js"; import { t } from "../services/i18n.js"; +import katex from "../services/math.js"; import options from "../services/options.js"; import OnClickButtonWidget from "./buttons/onclick_button.js"; import RightPanelWidget from "./right_panel_widget.js"; +import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify"; + +/** + * DOMPurify configuration for highlight list items. Uses built-in HTML and + * MathML profiles for proper namespace handling (KaTeX equations), then + * restricts to inline-only elements via FORBID_TAGS. + */ +const HIGHLIGHT_PURIFY_CONFIG: DOMPurifyConfig = { + USE_PROFILES: { html: true, mathMl: true }, + FORBID_TAGS: [ + "script", "style", "iframe", "object", "embed", "link", "meta", + "base", "noscript", "template", "form", "input", "textarea", + "button", "select", "option", + "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", + "blockquote", "pre", "section", "article", "aside", "nav", + "header", "footer", "main", "figure", "figcaption", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", + "ul", "ol", "li", "dl", "dt", "dd", + "hr", "img", "video", "audio", "picture", "canvas", + "svg", "foreignObject" + ], + FORBID_ATTR: [ + "onerror", "onload", "onclick", "onmouseover", "onfocus", + "onblur", "onsubmit", "onreset", "onchange", "oninput", + "onkeydown", "onkeyup", "onkeypress" + ], + RETURN_DOM: false, + RETURN_DOM_FRAGMENT: false +}; const TPL = /*html*/`
`; + const clean = sanitizeSvg(svg); + expect(clean).toContain("