core: make html validation more resilient

This commit is contained in:
Abdullah Atta
2026-04-07 20:34:39 +05:00
parent 206157b68b
commit d30ec4a19d
4 changed files with 204 additions and 12 deletions

View File

@@ -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
});

View File

@@ -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"');
});
});

View File

@@ -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;
}

View File

@@ -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>`;
}