From 206157b68befde7a71834ef7368795b7f1a231bb Mon Sep 17 00:00:00 2001
From: Ammar Ahmed
Date: Tue, 7 Apr 2026 12:02:23 +0500
Subject: [PATCH] core: handle invalid html inputs
---
.../src/utils/__tests__/html-parser.test.ts | 141 ++++++++++++++++
packages/core/src/utils/html-parser.ts | 150 +++++++++++++++++-
2 files changed, 287 insertions(+), 4 deletions(-)
create mode 100644 packages/core/src/utils/__tests__/html-parser.test.ts
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..4ffe935d4
--- /dev/null
+++ b/packages/core/src/utils/__tests__/html-parser.test.ts
@@ -0,0 +1,141 @@
+/*
+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 } 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: "THello
",
+ expected:
+ "THello
"
+ },
+ {
+ 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: "
",
+ expected: "
"
+ },
+ {
+ 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(
+ ""
+ );
+ });
+});
diff --git a/packages/core/src/utils/html-parser.ts b/packages/core/src/utils/html-parser.ts
index 82a90932a..9d385edb2 100644
--- a/packages/core/src/utils/html-parser.ts
+++ b/packages/core/src/utils/html-parser.ts
@@ -17,7 +17,7 @@ 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";
@@ -30,9 +30,7 @@ export const parseHTML = (input: string) =>
: null;
export const sanitizeHtml = (html: string) => {
- const inputHtml = html.includes("")
- ? html
- : `${html}`;
+ const inputHtml = normalizeToHtmlBody(html);
return getDomPurify().sanitize(inputHtml);
};
@@ -52,6 +50,150 @@ 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;
+
+ // Extract all opening and closing tag names
+ const openTagMatches = trimmed.matchAll(/<([a-z][a-z0-9]*)\b/gi);
+ const closeTagMatches = trimmed.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 tags)
+ const openContentTags = openTags.filter((tag) => !documentTags.has(tag));
+ const closeContentTags = closeTags.filter((tag) => !documentTags.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();
+ 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;