mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-07-10 04:21:21 +02:00
core: make html validation more resilient
This commit is contained in:
@@ -215,7 +215,7 @@ export async function handleInboxItems(
|
||||
JSON.parse(decryptedItem)
|
||||
);
|
||||
if (!validation.success) {
|
||||
logger.info("Failed to validate inbox item.", {
|
||||
logger.warn("Failed to validate inbox item.", {
|
||||
inboxItem: item,
|
||||
errors: validation.error.issues
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 { normalizeToHtmlBody, sanitizeHtml } from "../html-parser.js";
|
||||
import { expect, describe, it } from "vitest";
|
||||
|
||||
const HTML_INPUT_TYPES: Array<{
|
||||
@@ -139,3 +139,161 @@ describe("normalizeToHtmlBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 <script> tags and their content", () => {
|
||||
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
expect(result).toContain("Hello");
|
||||
});
|
||||
|
||||
it("strips inline event handlers", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)">');
|
||||
expect(result).not.toContain("onerror");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("strips javascript: URIs from href", () => {
|
||||
// eslint-disable-next-line no-script-url
|
||||
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>');
|
||||
expect(result).not.toContain("javascript:");
|
||||
expect(result).toContain("click");
|
||||
});
|
||||
|
||||
it("strips javascript: URIs from src", () => {
|
||||
// eslint-disable-next-line no-script-url
|
||||
const result = sanitizeHtml(
|
||||
'<iframe src="javascript:alert(document.domain)"></iframe>'
|
||||
);
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips onclick and other on* attributes", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<button onclick="evil()">OK</button><div onmouseover="evil()">x</div>'
|
||||
);
|
||||
expect(result).not.toContain("onclick");
|
||||
expect(result).not.toContain("onmouseover");
|
||||
expect(result).not.toContain("evil()");
|
||||
});
|
||||
|
||||
it("strips <object> and <embed> tags", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<object data="malicious.swf"></object><embed src="evil.swf">'
|
||||
);
|
||||
expect(result).not.toContain("<object");
|
||||
expect(result).not.toContain("<embed");
|
||||
});
|
||||
|
||||
it("strips data: URIs in dangerous attributes", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<a href="data:text/html,<script>alert(1)</script>">x</a>'
|
||||
);
|
||||
expect(result).not.toMatch(/href=["']data:/i);
|
||||
});
|
||||
|
||||
it("preserves safe block elements", () => {
|
||||
const input = "<p>Hello <strong>world</strong></p><ul><li>item</li></ul>";
|
||||
const result = sanitizeHtml(input);
|
||||
expect(result).toContain("<p>");
|
||||
expect(result).toContain("<strong>world</strong>");
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>item</li>");
|
||||
});
|
||||
|
||||
it("preserves safe links with http/https href", () => {
|
||||
const result = sanitizeHtml('<a href="https://notesnook.com">Notes</a>');
|
||||
expect(result).toContain('href="https://notesnook.com"');
|
||||
expect(result).toContain("Notes");
|
||||
});
|
||||
|
||||
it("preserves headings", () => {
|
||||
const result = sanitizeHtml("<h1>Title</h1><h2>Subtitle</h2>");
|
||||
expect(result).toContain("<h1>Title</h1>");
|
||||
expect(result).toContain("<h2>Subtitle</h2>");
|
||||
});
|
||||
|
||||
it("returns a string (not TrustedHTML or DOM node)", () => {
|
||||
const result = sanitizeHtml("<p>test</p>");
|
||||
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(
|
||||
"<div><p><span onmouseover=\"alert('xss')\">hover</span></p></div>"
|
||||
);
|
||||
expect(result).not.toContain("onmouseover");
|
||||
expect(result).toContain("hover");
|
||||
});
|
||||
|
||||
it("strips <base> tag that could hijack relative URLs", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<base href="https://evil.com"><a href="/path">link</a>'
|
||||
);
|
||||
expect(result).not.toContain("<base");
|
||||
});
|
||||
|
||||
it("preserves <iframe> with safe https src", () => {
|
||||
const result = sanitizeHtml('<iframe src="https://example.com"></iframe>');
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).toContain('src="https://example.com"');
|
||||
});
|
||||
|
||||
it("strips src from <iframe> with javascript: URI", () => {
|
||||
// eslint-disable-next-line no-script-url
|
||||
const result = sanitizeHtml(
|
||||
'<iframe src="javascript:alert(document.domain)"></iframe>'
|
||||
);
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips src from <iframe> with data: URI", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<iframe src="data:text/html,<script>alert(1)</script>"></iframe>'
|
||||
);
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).not.toContain("data:");
|
||||
});
|
||||
|
||||
it("strips srcdoc from <iframe>", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<iframe srcdoc="<script>alert(1)</script>"></iframe>'
|
||||
);
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).not.toContain("srcdoc");
|
||||
});
|
||||
|
||||
it("strips event handlers from <iframe>", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<iframe src="https://example.com" onload="steal()"></iframe>'
|
||||
);
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).not.toContain("onload");
|
||||
expect(result).not.toContain("steal()");
|
||||
});
|
||||
|
||||
it("preserves nested <iframe> with safe src alongside other elements", () => {
|
||||
const result = sanitizeHtml(
|
||||
'<div><p>Safe content</p><iframe src="https://example.com"></iframe></div>'
|
||||
);
|
||||
expect(result).toContain("Safe content");
|
||||
expect(result).toContain("<iframe");
|
||||
expect(result).toContain('src="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ function createPurifyWindow() {
|
||||
"<!doctype html><html><head><title>dompurify</title></head><body></body></html>"
|
||||
);
|
||||
const win = document?.defaultView as any;
|
||||
if (!win)
|
||||
throw new Error("DOMParser is unavailable; cannot initialize DOMPurify");
|
||||
const doc = win.document;
|
||||
|
||||
// DOMPurify calls new DOMParser().parseFromString(...)
|
||||
@@ -92,9 +94,22 @@ function createPurifyWindow() {
|
||||
|
||||
let domPurify: DOMPurify.DOMPurify | undefined = undefined;
|
||||
function getDomPurify() {
|
||||
if (DOMPurify.isSupported) return DOMPurify;
|
||||
if (!domPurify) {
|
||||
domPurify = DOMPurify(createPurifyWindow());
|
||||
const win = DOMPurify.isSupported
|
||||
? (globalThis as unknown as WindowLike)
|
||||
: createPurifyWindow();
|
||||
domPurify = DOMPurify(win);
|
||||
// Only allow https/http src on iframes — strip anything else (javascript:,
|
||||
// data:, relative paths that could be weaponized, etc.).
|
||||
domPurify.addHook("uponSanitizeAttribute", (node, data) => {
|
||||
if (
|
||||
node.tagName === "IFRAME" &&
|
||||
data.attrName === "src" &&
|
||||
!/^https?:\/\//i.test(data.attrValue)
|
||||
) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return domPurify;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,12 @@ export const parseHTML = (input: string) =>
|
||||
)
|
||||
: null;
|
||||
|
||||
export const sanitizeHtml = (html: string) => {
|
||||
export const sanitizeHtml = (html: string): string => {
|
||||
const inputHtml = normalizeToHtmlBody(html);
|
||||
return getDomPurify().sanitize(inputHtml);
|
||||
return getDomPurify().sanitize(inputHtml, {
|
||||
RETURN_DOM: false,
|
||||
ADD_TAGS: ["iframe"]
|
||||
}) as string;
|
||||
};
|
||||
|
||||
export function getDummyDocument() {
|
||||
@@ -71,9 +74,16 @@ function isHtmlValid(html: string): boolean {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
// Strip comments and script/style content before tag counting to avoid
|
||||
// false matches on tags appearing inside comments or raw text blocks.
|
||||
const stripped = trimmed
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
|
||||
|
||||
// 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 openTagMatches = stripped.matchAll(/<([a-z][a-z0-9]*)\b/gi);
|
||||
const closeTagMatches = stripped.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());
|
||||
@@ -81,9 +91,14 @@ function isHtmlValid(html: string): boolean {
|
||||
// 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));
|
||||
// Count content tags (non-document, non-void tags) — void/self-closing elements
|
||||
// never have a closing tag so they must not affect the balance check.
|
||||
const openContentTags = openTags.filter(
|
||||
(tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag)
|
||||
);
|
||||
const closeContentTags = closeTags.filter(
|
||||
(tag) => !documentTags.has(tag) && !SELF_CLOSING_TAGS.has(tag)
|
||||
);
|
||||
|
||||
// For content tags: opening and closing must match
|
||||
if (openContentTags.length !== closeContentTags.length) {
|
||||
@@ -103,6 +118,10 @@ function isHtmlValid(html: string): boolean {
|
||||
},
|
||||
onclosetag: (name) => {
|
||||
const nameLower = name.toLowerCase();
|
||||
// htmlparser2 fires onclosetag for void/self-closing elements immediately
|
||||
// after onopentag. We never push them onto the stack, so skip here too.
|
||||
if (SELF_CLOSING_TAGS.has(nameLower)) return;
|
||||
|
||||
const lastOpen = openStack[openStack.length - 1];
|
||||
|
||||
if (!lastOpen) {
|
||||
@@ -177,7 +196,7 @@ export function normalizeToHtmlBody(input: string) {
|
||||
}
|
||||
|
||||
if (headBlock) {
|
||||
const bodyContent = inner.replace(headBlock!, "").trim();
|
||||
const bodyContent = inner.replace(headBlock, "").trim();
|
||||
return `<html>${headBlock}<body>${bodyContent}</body></html>`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user