core: handle invalid html inputs

This commit is contained in:
Ammar Ahmed
2026-04-07 12:02:23 +05:00
parent 61a56662eb
commit 206157b68b
2 changed files with 287 additions and 4 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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: "<html><body></body></html>"
},
{
title: "whitespace input",
input: " \n\t ",
expected: "<html><body></body></html>"
},
{
title: "plain text",
input: "Hello world",
expected: "<html><body>Hello world</body></html>"
},
{
title: "html fragment",
input: "<p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "complete html with body",
input: "<html><body><p>Hello</p></body></html>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "complete html with body attributes",
input: '<html><body class="editor" data-id="1">Hello</body></html>',
expected: '<html><body class="editor" data-id="1">Hello</body></html>'
},
{
title: "html without body",
input: "<html><head><title>T</title></head><p>Hello</p></html>",
expected:
"<html><head><title>T</title></head><body><p>Hello</p></body></html>"
},
{
title: "doctype html without body",
input: "<!doctype html><html><head></head><div>Hello</div></html>",
expected: "<html><head></head><body><div>Hello</div></body></html>"
},
{
title: "body without html",
input: "<body><p>Hello</p></body>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "body with attributes without html",
input: '<body class="editor"><p>Hello</p></body>',
expected: '<html><body class="editor"><p>Hello</p></body></html>'
},
{
title: "body without closing tag",
input: "<body><p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
},
{
title: "html with unclosed body",
input: '<html><body class="editor"><p>Hello</p></html>',
expected: '<html><body class="editor"><p>Hello</p></body></html>'
},
{
title: "uppercase tags",
input: "<HTML><BODY><p>Hello</p></BODY></HTML>",
expected: "<html><BODY><p>Hello</p></BODY></html>"
},
{
title: "orphaned closing tag",
input: "Hello</p>World",
expected:
"<html><body><pre><code>Hello&lt;/p&gt;World</code></pre></body></html>"
},
{
title: "unclosed tag at end",
input: "<div><p>Hello",
expected:
"<html><body><pre><code>&lt;div&gt;&lt;p&gt;Hello</code></pre></body></html>"
},
{
title: "mismatched closing tags",
input: "<div><p>Hello</div></p>",
expected: "<html><body><div><p>Hello</div></p></body></html>"
},
{
title: "unclosed body tag in fragment",
input: "<body><p>Hello</p>",
expected: "<html><body><p>Hello</p></body></html>"
}
];
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("<html>")).toBe(true);
expect(normalized.includes("<body")).toBe(true);
expect(normalized.endsWith("</body></html>")).toBe(true);
}
});
it("should handle runtime non-string values safely", () => {
expect(normalizeToHtmlBody(null as unknown as string)).toBe(
"<html><body></body></html>"
);
expect(normalizeToHtmlBody(undefined as unknown as string)).toBe(
"<html><body></body></html>"
);
});
});

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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
: `<html><body>${html}</body></html>`;
const inputHtml = normalizeToHtmlBody(html);
return getDomPurify().sanitize(inputHtml);
};
@@ -52,6 +50,150 @@ function wrapIntoHTMLDocument(input: string) {
return `<!doctype html><html lang="en"><head><title>Document Fragment</title></head><body>${input}</body></html>`;
}
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 `<html><body><pre><code>${escaped}</code></pre></body></html>`;
}
export function normalizeToHtmlBody(input: string) {
const source = typeof input === "string" ? input.trim() : "";
if (!source) return "<html><body></body></html>";
// If HTML has broken/incomplete tags, wrap in code block for display
if (!isHtmlValid(source)) {
return wrapInCodeBlock(source);
}
const hasHtmlTag = /<html\b[^>]*>/i.test(source);
const hasBodyOpenTag = /<body\b[^>]*>/i.test(source);
const hasBodyCloseTag = /<\/body>/i.test(source);
// If a full body block exists, normalize to <html><body...>...</body></html>.
const bodyBlock = source.match(/<body\b[^>]*>[\s\S]*?<\/body>/i)?.[0];
if (bodyBlock) {
return `<html>${bodyBlock}</html>`;
}
// HTML exists but no complete body: strip outer html and wrap remaining content in body.
if (hasHtmlTag) {
const inner = source
.replace(/<!doctype[^>]*>/i, "")
.replace(/<html\b[^>]*>/i, "")
.replace(/<\/html>/i, "")
.trim();
const headBlock = inner.match(/<head\b[^>]*>[\s\S]*?<\/head>/i)?.[0];
// Handle case with <body ...> present but missing </body>.
if (hasBodyOpenTag && !hasBodyCloseTag) {
const bodyOpen = inner.match(/<body\b[^>]*>/i)?.[0] || "<body>";
const bodyContent = inner.replace(/<body\b[^>]*>/i, "");
return `<html>${bodyOpen}${bodyContent}</body></html>`;
}
if (headBlock) {
const bodyContent = inner.replace(headBlock!, "").trim();
return `<html>${headBlock}<body>${bodyContent}</body></html>`;
}
return `<html><body>${inner}</body></html>`;
}
// Body exists without html: add html wrapper, and close body if needed.
if (hasBodyOpenTag) {
if (!hasBodyCloseTag) return `<html>${source}</body></html>`;
return `<html>${source}</html>`;
}
// Plain fragment/text.
return `<html><body>${source}</body></html>`;
}
export function extractHeadline(html: string) {
let text = "";
let start = false;