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 = ' ';
+ expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
+ });
+
+ it("preserves tables", () => {
+ const html = '';
+ 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 = '';
+ 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("';
+ const result = sanitizeNoteContentHtml(html);
+ expect(result).not.toContain("">Click';
+ const result = sanitizeNoteContentHtml(html);
+ // DOMPurify should either strip the href or remove the dangerous content
+ expect(result).not.toContain("';
+ const result = sanitizeNoteContentHtml(html);
+ expect(result).not.toContain(" `;
+ const clean = sanitizeSvg(dirty);
+ expect(clean).not.toContain("`;
+ const clean = sanitizeSvg(dirty);
+ expect(clean).not.toContain("SCRIPT");
+ expect(clean).not.toContain("alert");
+ });
+
+ it("strips `;
+ const clean = sanitizeSvg(dirty);
+ expect(clean).not.toContain("