mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-07-10 12:37:16 +02:00
core: handle invalid html inputs
This commit is contained in:
141
packages/core/src/utils/__tests__/html-parser.test.ts
Normal file
141
packages/core/src/utils/__tests__/html-parser.test.ts
Normal 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</p>World</code></pre></body></html>"
|
||||
},
|
||||
{
|
||||
title: "unclosed tag at end",
|
||||
input: "<div><p>Hello",
|
||||
expected:
|
||||
"<html><body><pre><code><div><p>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>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user