diff --git a/apps/client/package.json b/apps/client/package.json
index 1441e9799a..b52b591f9b 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -41,6 +41,7 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
+ "dompurify": "3.2.5",
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"globals": "17.3.0",
diff --git a/apps/client/src/services/content_renderer_text.ts b/apps/client/src/services/content_renderer_text.ts
index e72f9e178b..34aabf6099 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/doc_renderer.ts b/apps/client/src/services/doc_renderer.ts
index 1ae60fb9ca..829bc9ac55 100644
--- a/apps/client/src/services/doc_renderer.ts
+++ b/apps/client/src/services/doc_renderer.ts
@@ -9,6 +9,15 @@ export default function renderDoc(note: FNote) {
const $content = $("");
if (docName) {
+ // Sanitize docName to prevent path traversal attacks (e.g.,
+ // "../../../../api/notes/_malicious/open?x=" escaping doc_notes).
+ docName = sanitizeDocName(docName);
+ if (!docName) {
+ console.warn("Blocked potentially malicious docName attribute value.");
+ resolve($content);
+ return;
+ }
+
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
$content.load(url, async (response, status) => {
@@ -48,6 +57,31 @@ async function processContent(url: string, $content: JQuery
) {
await applyReferenceLinks($content[0]);
}
+function sanitizeDocName(docNameValue: string): string | null {
+ // Strip any path traversal sequences and dangerous URL characters.
+ // Legitimate docName values are simple paths like "User Guide/Topic" or
+ // "launchbar_intro" — they only contain alphanumeric chars, underscores,
+ // hyphens, spaces, and forward slashes for subdirectories.
+ // Reject values containing path traversal (../, ..\) or URL control
+ // characters (?, #, :, @) that could be used to escape the doc_notes
+ // directory or manipulate the resulting URL.
+ if (/\.\.|[?#:@\\]/.test(docNameValue)) {
+ return null;
+ }
+
+ // Remove any leading slashes to prevent absolute path construction.
+ docNameValue = docNameValue.replace(/^\/+/, "");
+
+ // After stripping, ensure only safe characters remain:
+ // alphanumeric, spaces, underscores, hyphens, forward slashes, and periods
+ // (periods are allowed for filenames but .. was already rejected above).
+ if (!/^[a-zA-Z0-9 _\-/.']+$/.test(docNameValue)) {
+ return null;
+ }
+
+ return docNameValue;
+}
+
function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");
diff --git a/apps/client/src/services/note_tooltip.ts b/apps/client/src/services/note_tooltip.ts
index 60af420468..415a1010c2 100644
--- a/apps/client/src/services/note_tooltip.ts
+++ b/apps/client/src/services/note_tooltip.ts
@@ -7,6 +7,7 @@ import contentRenderer from "./content_renderer.js";
import appContext from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
+import { sanitizeNoteContentHtml } from "./sanitize_content.js";
// Track all elements that open tooltips
let openTooltipElements: JQuery[] = [];
@@ -90,7 +91,8 @@ async function mouseEnterHandler(this: HTMLElement) {
return;
}
- const html = `${content}
`;
+ 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
@@ -108,6 +110,8 @@ async function mouseEnterHandler(this: HTMLElement) {
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 0000000000..3573ba4ccc
--- /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("