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 = '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(" for included + * notes,
/
for images and tables). + * + * Notably absent: `; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" tags case-insensitively", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("SCRIPT"); + expect(clean).not.toContain("alert"); + }); + + it("strips `; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" tags", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("foreignObject"); + expect(clean).not.toContain("alert"); + }); + + it("strips `; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(" { + it("strips onload from SVG root", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onload"); + expect(clean).not.toContain("alert"); + expect(clean).toContain(" { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onclick"); + expect(clean).not.toContain("alert"); + expect(clean).toContain("r=\"50\""); + }); + + it("strips onerror from elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onerror"); + expect(clean).not.toContain("alert"); + }); + + it("strips onmouseover from elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onmouseover"); + }); + + it("strips onfocus from elements", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onfocus"); + }); + }); + + describe("removes dangerous URI schemes", () => { + it("strips javascript: URIs from href", () => { + const dirty = `Click`; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("javascript:"); + expect(clean).toContain("Click"); + }); + + it("strips javascript: URIs from xlink:href", () => { + const dirty = `Click`; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("javascript:"); + }); + + it("strips data:text/html URIs", () => { + const dirty = `Click`; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("data:text/html"); + }); + + it("strips vbscript: URIs", () => { + const dirty = `Click`; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("vbscript:"); + }); + + it("strips javascript: URIs with whitespace padding", () => { + const dirty = `Click`; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("javascript:"); + }); + }); + + describe("removes xml-stylesheet processing instructions", () => { + it("strips xml-stylesheet PIs", () => { + const dirty = ``; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("xml-stylesheet"); + expect(clean).toContain(" { + it("preserves basic SVG shapes", () => { + const svg = ``; + const clean = sanitizeSvg(svg); + expect(clean).toBe(svg); + }); + + it("preserves SVG paths", () => { + const svg = ``; + const clean = sanitizeSvg(svg); + expect(clean).toBe(svg); + }); + + it("preserves SVG text elements", () => { + const svg = `Hello World`; + const clean = sanitizeSvg(svg); + expect(clean).toBe(svg); + }); + + it("preserves SVG groups and transforms", () => { + const svg = ``; + const clean = sanitizeSvg(svg); + expect(clean).toBe(svg); + }); + + it("preserves SVG style elements with CSS (not script)", () => { + const svg = ``; + const clean = sanitizeSvg(svg); + expect(clean).toContain("