diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 3efc9c59c..1bc65ed22 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -23,6 +23,7 @@ "dayjs": "1.11.13", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", + "dompurify": "^3.3.3", "domutils": "^3.1.0", "entities": "5.0.0", "fuzzyjs": "^5.0.1", @@ -35,7 +36,8 @@ "prismjs": "^1.29.0", "qclone": "^1.2.0", "rfdc": "^1.3.0", - "spark-md5": "^3.0.2" + "spark-md5": "^3.0.2", + "zod": "^4.3.6" }, "devDependencies": { "@notesnook/crypto": "file:../crypto", @@ -1184,6 +1186,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1881,6 +1890,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -4231,6 +4249,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/packages/core/package.json b/packages/core/package.json index f4df81446..2101db463 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,7 @@ "dayjs": "1.11.13", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", + "dompurify": "^3.3.3", "domutils": "^3.1.0", "entities": "5.0.0", "fuzzyjs": "^5.0.1", @@ -90,7 +91,8 @@ "prismjs": "^1.29.0", "qclone": "^1.2.0", "rfdc": "^1.3.0", - "spark-md5": "^3.0.2" + "spark-md5": "^3.0.2", + "zod": "^4.3.6" }, "overrides": { "htmlparser2": "^10.0.0" diff --git a/packages/core/src/api/sync/merger.ts b/packages/core/src/api/sync/merger.ts index a35258d18..4cbffa450 100644 --- a/packages/core/src/api/sync/merger.ts +++ b/packages/core/src/api/sync/merger.ts @@ -28,7 +28,9 @@ import { Note, isDeleted } from "../../types.js"; -import { ParsedInboxItem, SyncInboxItem } from "./types.js"; +import { SyncInboxItem } from "./types.js"; +import { z } from "zod"; +import { sanitizeHtml } from "../../utils/html-parser.js"; const THRESHOLD = process.env.NODE_ENV === "test" ? 2 * 1000 : 60 * 1000; class Merger { @@ -168,6 +170,25 @@ export function isContentConflicted( } } +const RawInboxItemSchema = z.object({ + title: z.string().min(1, "Title is required"), + pinned: z.boolean().optional(), + favorite: z.boolean().optional(), + readonly: z.boolean().optional(), + archived: z.boolean().optional(), + notebookIds: z.array(z.string()).optional(), + tagIds: z.array(z.string()).optional(), + type: z.enum(["note"]), + source: z.string(), + version: z.literal(1), + content: z + .object({ + type: z.enum(["html"]), + data: z.string() + }) + .optional() +}); + export async function handleInboxItems( inboxItems: SyncInboxItem[], db: Database @@ -190,34 +211,37 @@ export async function handleInboxItems( const decryptedItem = await db .storage() .decryptPGPMessage(inboxKeys.privateKey, item.cipher); - const parsed = JSON.parse(decryptedItem) as ParsedInboxItem; - - if (parsed.type !== "note") { - continue; - } - if (parsed.version !== 1) { + const validation = RawInboxItemSchema.safeParse( + JSON.parse(decryptedItem) + ); + if (!validation.success) { + logger.warn("Failed to validate inbox item.", { + inboxItem: item, + errors: validation.error.issues + }); continue; } + const data = validation.data; await db.notes.add({ id: item.id, - title: parsed.title, - favorite: parsed.favorite, - pinned: parsed.pinned, - readonly: parsed.readonly, + title: data.title, + favorite: data.favorite, + pinned: data.pinned, + readonly: data.readonly, content: { - data: parsed?.content?.data ?? "", + data: sanitizeHtml(data?.content?.data ?? ""), type: "tiptap" } }); - if (parsed.archived !== undefined) { - await db.notes.archive(parsed.archived, item.id); + if (data.archived !== undefined) { + await db.notes.archive(data.archived, item.id); } - for (const notebookId of parsed.notebookIds || []) { + for (const notebookId of data.notebookIds || []) { if (!(await db.notebooks.exists(notebookId))) continue; await db.notes.addToNotebook(notebookId, item.id); } - for (const tagId of parsed.tagIds || []) { + for (const tagId of data.tagIds || []) { if (!(await db.tags.exists(tagId))) continue; await db.relations.add( { type: "tag", id: tagId }, diff --git a/packages/core/src/api/sync/types.ts b/packages/core/src/api/sync/types.ts index f7e10ddb8..efba6eb92 100644 --- a/packages/core/src/api/sync/types.ts +++ b/packages/core/src/api/sync/types.ts @@ -64,20 +64,3 @@ export type SyncInboxItem = { cipher: string; alg: string; }; - -export type ParsedInboxItem = { - title: string; - pinned?: boolean; - favorite?: boolean; - readonly?: boolean; - archived?: boolean; - notebookIds?: string[]; - tagIds?: string[]; - type: "note"; - source: string; - version: 1; - content?: { - type: "html"; - data: string; - }; -}; diff --git a/packages/core/src/utils/__tests__/html-parser.test.ts b/packages/core/src/utils/__tests__/html-parser.test.ts new file mode 100644 index 000000000..b30fe685d --- /dev/null +++ b/packages/core/src/utils/__tests__/html-parser.test.ts @@ -0,0 +1,299 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { normalizeToHtmlBody, sanitizeHtml } from "../html-parser.js"; +import { expect, describe, it } from "vitest"; + +const HTML_INPUT_TYPES: Array<{ + title: string; + input: string; + expected: string; +}> = [ + { + title: "empty input", + input: "", + expected: "" + }, + { + title: "whitespace input", + input: " \n\t ", + expected: "" + }, + { + title: "plain text", + input: "Hello world", + expected: "Hello world" + }, + { + title: "html fragment", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "complete html with body", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "complete html with body attributes", + input: 'Hello', + expected: 'Hello' + }, + { + title: "html without body", + input: "T

Hello

", + expected: + "T

Hello

" + }, + { + title: "doctype html without body", + input: "
Hello
", + expected: "
Hello
" + }, + { + title: "body without html", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "body with attributes without html", + input: '

Hello

', + expected: '

Hello

' + }, + { + title: "body without closing tag", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "html with unclosed body", + input: '

Hello

', + expected: '

Hello

' + }, + { + title: "uppercase tags", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "orphaned closing tag", + input: "Hello

World", + expected: + "
Hello</p>World
" + }, + { + title: "unclosed tag at end", + input: "

Hello", + expected: + "

<div><p>Hello
" + }, + { + title: "mismatched closing tags", + input: "

Hello

", + expected: "

Hello

" + }, + { + title: "unclosed body tag in fragment", + input: "

Hello

", + expected: "

Hello

" + } +]; + +describe("normalizeToHtmlBody", () => { + HTML_INPUT_TYPES.forEach(({ title, input, expected }) => { + it(`should normalize ${title}`, () => { + expect(normalizeToHtmlBody(input)).toBe(expected); + }); + }); + + it("should always return html and body sequence at root", () => { + for (const { input } of HTML_INPUT_TYPES) { + const normalized = normalizeToHtmlBody(input).toLowerCase(); + expect(normalized.startsWith("")).toBe(true); + expect(normalized.includes("")).toBe(true); + } + }); + + it("should handle runtime non-string values safely", () => { + expect(normalizeToHtmlBody(null as unknown as string)).toBe( + "" + ); + expect(normalizeToHtmlBody(undefined as unknown as string)).toBe( + "" + ); + }); +}); + +// sanitizeHtml uses globalThis.DOMParser (set to linkedom's DOMParser in +// test.setup.ts) to back DOMPurify when a native browser DOM is unavailable. +describe("sanitizeHtml", () => { + it("strips "); + expect(result).not.toContain(" { + const result = sanitizeHtml(''); + expect(result).not.toContain("onerror"); + expect(result).not.toContain("alert(1)"); + }); + + it("strips javascript: URIs from href", () => { + // eslint-disable-next-line no-script-url + const result = sanitizeHtml('click'); + expect(result).not.toContain("javascript:"); + expect(result).toContain("click"); + }); + + it("strips javascript: URIs from src", () => { + // eslint-disable-next-line no-script-url + const result = sanitizeHtml( + '' + ); + expect(result).not.toContain("javascript:"); + }); + + it("strips onclick and other on* attributes", () => { + const result = sanitizeHtml( + '
x
' + ); + expect(result).not.toContain("onclick"); + expect(result).not.toContain("onmouseover"); + expect(result).not.toContain("evil()"); + }); + + it("strips and tags", () => { + const result = sanitizeHtml( + '' + ); + expect(result).not.toContain(" { + const result = sanitizeHtml( + 'x' + ); + expect(result).not.toMatch(/href=["']data:/i); + }); + + it("preserves safe block elements", () => { + const input = "

Hello world

  • item
"; + const result = sanitizeHtml(input); + expect(result).toContain("

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

    "); + expect(result).toContain("
  • item
  • "); + }); + + it("preserves safe links with http/https href", () => { + const result = sanitizeHtml('Notes'); + expect(result).toContain('href="https://notesnook.com"'); + expect(result).toContain("Notes"); + }); + + it("preserves headings", () => { + const result = sanitizeHtml("

    Title

    Subtitle

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

    Title

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

    Subtitle

    "); + }); + + it("returns a string (not TrustedHTML or DOM node)", () => { + const result = sanitizeHtml("

    test

    "); + expect(typeof result).toBe("string"); + }); + + it("handles empty input without throwing", () => { + expect(() => sanitizeHtml("")).not.toThrow(); + const result = sanitizeHtml(""); + expect(typeof result).toBe("string"); + }); + + it("handles plain text without throwing", () => { + const result = sanitizeHtml("just plain text"); + expect(result).toContain("just plain text"); + expect(typeof result).toBe("string"); + }); + + it("handles deeply nested XSS attempts", () => { + const result = sanitizeHtml( + "

    hover

    " + ); + expect(result).not.toContain("onmouseover"); + expect(result).toContain("hover"); + }); + + it("strips tag that could hijack relative URLs", () => { + const result = sanitizeHtml( + 'link' + ); + expect(result).not.toContain(" with safe https src", () => { + const result = sanitizeHtml(''); + expect(result).toContain(" with javascript: URI", () => { + // eslint-disable-next-line no-script-url + const result = sanitizeHtml( + '' + ); + expect(result).toContain(" with data: URI", () => { + const result = sanitizeHtml( + '' + ); + expect(result).toContain("", () => { + const result = sanitizeHtml( + '' + ); + expect(result).toContain("", () => { + const result = sanitizeHtml( + '' + ); + expect(result).toContain(" with safe src alongside other elements", () => { + const result = sanitizeHtml( + '

    Safe content

    ' + ); + expect(result).toContain("Safe content"); + expect(result).toContain(". +*/ + +import DOMPurify, { WindowLike } from "dompurify"; + +const parseHTML = (input: string) => + "DOMParser" in globalThis + ? new globalThis.DOMParser().parseFromString(input, "text/html") + : null; + +function ensureNodeFilter(win: any) { + if (win.NodeFilter) return; + + function NodeFilter() {} + + NodeFilter.FILTER_ACCEPT = 1; + NodeFilter.FILTER_REJECT = 2; + NodeFilter.FILTER_SKIP = 3; + + NodeFilter.SHOW_ALL = 0xff_ff_ff_ff; + NodeFilter.SHOW_ELEMENT = 0x1; + NodeFilter.SHOW_ATTRIBUTE = 0x2; + NodeFilter.SHOW_TEXT = 0x4; + NodeFilter.SHOW_CDATA_SECTION = 0x8; + NodeFilter.SHOW_ENTITY_REFERENCE = 0x10; + NodeFilter.SHOW_ENTITY = 0x20; + NodeFilter.SHOW_PROCESSING_INSTRUCTION = 0x40; + NodeFilter.SHOW_COMMENT = 0x80; + NodeFilter.SHOW_DOCUMENT = 0x1_00; + NodeFilter.SHOW_DOCUMENT_TYPE = 0x2_00; + NodeFilter.SHOW_DOCUMENT_FRAGMENT = 0x4_00; + NodeFilter.SHOW_NOTATION = 0x8_00; + + win.NodeFilter = NodeFilter; +} + +function createPurifyWindow() { + const document = parseHTML( + "dompurify" + ); + const win = document?.defaultView as any; + if (!win) + throw new Error("DOMParser is unavailable; cannot initialize DOMPurify"); + const doc = win.document; + + // DOMPurify calls new DOMParser().parseFromString(...) + if (!win.DOMParser) { + win.DOMParser = DOMParser; + } + + ensureNodeFilter(win); + + // DOMPurify checks existence AND later relies on callable behavior. + if (!doc.implementation) { + doc.implementation = {}; + } + + if (typeof doc.implementation.createHTMLDocument !== "function") { + doc.implementation.createHTMLDocument = (title = "") => { + const document = parseHTML( + "dompurify" + ); + return document; + }; + } + + if (typeof doc.implementation.createDocument !== "function") { + doc.implementation.createDocument = () => { + // Keep this as HTML doc because DOMPurify asks for body/html via getElementsByTagName. + return parseHTML( + "" + ); + }; + } + + return win as WindowLike; +} + +let domPurify: DOMPurify.DOMPurify | undefined = undefined; +function getDomPurify() { + if (!domPurify) { + const win = DOMPurify.isSupported + ? (globalThis as unknown as WindowLike) + : createPurifyWindow(); + domPurify = DOMPurify(win); + // Only allow https/http src on iframes — strip anything else (javascript:, + // data:, relative paths that could be weaponized, etc.). + domPurify.addHook("uponSanitizeAttribute", (node, data) => { + if ( + node.tagName === "IFRAME" && + data.attrName === "src" && + !/^https?:\/\//i.test(data.attrValue) + ) { + data.keepAttr = false; + } + }); + } + return domPurify; +} + +export { getDomPurify }; diff --git a/packages/core/src/utils/html-parser.ts b/packages/core/src/utils/html-parser.ts index 5f8ea6a5a..ebb99c630 100644 --- a/packages/core/src/utils/html-parser.ts +++ b/packages/core/src/utils/html-parser.ts @@ -17,8 +17,9 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { decodeHTML5 } from "entities"; +import { decodeHTML5, escape } from "entities"; import { Parser } from "htmlparser2"; +import { getDomPurify } from "./dom-purify"; export const parseHTML = (input: string) => "DOMParser" in globalThis @@ -28,6 +29,14 @@ export const parseHTML = (input: string) => ) : null; +export const sanitizeHtml = (html: string): string => { + const inputHtml = normalizeToHtmlBody(html); + return getDomPurify().sanitize(inputHtml, { + RETURN_DOM: false, + ADD_TAGS: ["iframe"] + }) as string; +}; + export function getDummyDocument() { const doc = parseHTML("
    "); return doc; @@ -44,6 +53,166 @@ function wrapIntoHTMLDocument(input: string) { return `Document Fragment${input}`; } +const SELF_CLOSING_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr" +]); + +function isHtmlValid(html: string): boolean { + const trimmed = html.trim(); + if (!trimmed) return true; + + // Strip comments and script/style content before tag counting to avoid + // false matches on tags appearing inside comments or raw text blocks. + const stripped = trimmed + .replace(//g, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + + // Extract all opening and closing tag names + const openTagMatches = stripped.matchAll(/<([a-z][a-z0-9]*)\b/gi); + const closeTagMatches = stripped.matchAll(/<\/([a-z][a-z0-9]*)\b/gi); + + const openTags = Array.from(openTagMatches, (m) => m[1].toLowerCase()); + const closeTags = Array.from(closeTagMatches, (m) => m[1].toLowerCase()); + + // Document-level tags (body, html, head) are allowed to be unclosed in fragments + const documentTags = new Set(["body", "html", "head"]); + + // Count content tags (non-document, non-void tags) — void/self-closing elements + // never have a closing tag so they must not affect the balance check. + const openContentTags = openTags.filter( + (tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag) + ); + const closeContentTags = closeTags.filter( + (tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag) + ); + + // For content tags: opening and closing must match + if (openContentTags.length !== closeContentTags.length) { + return false; + } + + // Now do strict tag matching for actual mismatches + const openStack: string[] = []; + let hasError = false; + + const parser = new Parser( + { + onopentag: (name) => { + if (!SELF_CLOSING_TAGS.has(name.toLowerCase())) { + openStack.push(name.toLowerCase()); + } + }, + onclosetag: (name) => { + const nameLower = name.toLowerCase(); + // htmlparser2 fires onclosetag for void/self-closing elements immediately + // after onopentag. We never push them onto the stack, so skip here too. + if (SELF_CLOSING_TAGS.has(nameLower)) return; + + const lastOpen = openStack[openStack.length - 1]; + + if (!lastOpen) { + hasError = true; + return; + } + + if (lastOpen === nameLower) { + openStack.pop(); + } else { + // Any tag mismatch is an error (except for auto-fixed document tags) + hasError = true; + } + } + }, + { + lowerCaseTags: true + } + ); + + try { + parser.end(html); + // Unclosed content tags = invalid + if (openStack.length > 0) { + return false; + } + return !hasError; + } catch { + return false; + } +} + +function wrapInCodeBlock(html: string): string { + const escaped = escape(html); + return `
    ${escaped}
    `; +} + +export function normalizeToHtmlBody(input: string) { + const source = typeof input === "string" ? input.trim() : ""; + if (!source) return ""; + + // If HTML has broken/incomplete tags, wrap in code block for display + if (!isHtmlValid(source)) { + return wrapInCodeBlock(source); + } + + const hasHtmlTag = /]*>/i.test(source); + const hasBodyOpenTag = /]*>/i.test(source); + const hasBodyCloseTag = /<\/body>/i.test(source); + + // If a full body block exists, normalize to .... + const bodyBlock = source.match(/]*>[\s\S]*?<\/body>/i)?.[0]; + if (bodyBlock) { + return `${bodyBlock}`; + } + + // HTML exists but no complete body: strip outer html and wrap remaining content in body. + if (hasHtmlTag) { + const inner = source + .replace(/]*>/i, "") + .replace(/]*>/i, "") + .replace(/<\/html>/i, "") + .trim(); + + const headBlock = inner.match(/]*>[\s\S]*?<\/head>/i)?.[0]; + + // Handle case with present but missing . + if (hasBodyOpenTag && !hasBodyCloseTag) { + const bodyOpen = inner.match(/]*>/i)?.[0] || ""; + const bodyContent = inner.replace(/]*>/i, ""); + return `${bodyOpen}${bodyContent}`; + } + + if (headBlock) { + const bodyContent = inner.replace(headBlock, "").trim(); + return `${headBlock}${bodyContent}`; + } + + return `${inner}`; + } + + // Body exists without html: add html wrapper, and close body if needed. + if (hasBodyOpenTag) { + if (!hasBodyCloseTag) return `${source}`; + return `${source}`; + } + + // Plain fragment/text. + return `${source}`; +} + export function extractHeadline(html: string) { let text = ""; let start = false;