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
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: "Hello
", + expected: + "Hello
" + }, + { + title: "doctype html without body", + 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: "HelloWorld", + expected: + "Hello</p>World"
+ },
+ {
+ title: "unclosed tag at end",
+ input: "Hello", + expected: + "
<div><p>Hello"
+ },
+ {
+ title: "mismatched closing tags",
+ input: "Hello
Hello
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("">x' + ); + expect(result).not.toMatch(/href=["']data:/i); + }); + + it("preserves safe block elements", () => { + const input = "Hello world
"); + expect(result).toContain("world"); + expect(result).toContain("
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