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: "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( + "" + ); + }); +}); 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;