mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
Merge pull request #9643 from streetwriters/core/validate-inbox-items
core: validate & sanitize inbox items
This commit is contained in:
29
packages/core/package-lock.json
generated
29
packages/core/package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"dayjs": "1.11.13",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"dompurify": "^3.3.3",
|
||||
"domutils": "^3.1.0",
|
||||
"entities": "5.0.0",
|
||||
"fuzzyjs": "^5.0.1",
|
||||
@@ -35,7 +36,8 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"qclone": "^1.2.0",
|
||||
"rfdc": "^1.3.0",
|
||||
"spark-md5": "^3.0.2"
|
||||
"spark-md5": "^3.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@notesnook/crypto": "file:../crypto",
|
||||
@@ -1184,6 +1186,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -1881,6 +1890,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -4231,6 +4249,15 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"dayjs": "1.11.13",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"dompurify": "^3.3.3",
|
||||
"domutils": "^3.1.0",
|
||||
"entities": "5.0.0",
|
||||
"fuzzyjs": "^5.0.1",
|
||||
@@ -90,7 +91,8 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"qclone": "^1.2.0",
|
||||
"rfdc": "^1.3.0",
|
||||
"spark-md5": "^3.0.2"
|
||||
"spark-md5": "^3.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"htmlparser2": "^10.0.0"
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
Note,
|
||||
isDeleted
|
||||
} from "../../types.js";
|
||||
import { ParsedInboxItem, SyncInboxItem } from "./types.js";
|
||||
import { SyncInboxItem } from "./types.js";
|
||||
import { z } from "zod";
|
||||
import { sanitizeHtml } from "../../utils/html-parser.js";
|
||||
|
||||
const THRESHOLD = process.env.NODE_ENV === "test" ? 2 * 1000 : 60 * 1000;
|
||||
class Merger {
|
||||
@@ -168,6 +170,25 @@ export function isContentConflicted(
|
||||
}
|
||||
}
|
||||
|
||||
const RawInboxItemSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
pinned: z.boolean().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
readonly: z.boolean().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
notebookIds: z.array(z.string()).optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
type: z.enum(["note"]),
|
||||
source: z.string(),
|
||||
version: z.literal(1),
|
||||
content: z
|
||||
.object({
|
||||
type: z.enum(["html"]),
|
||||
data: z.string()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export async function handleInboxItems(
|
||||
inboxItems: SyncInboxItem[],
|
||||
db: Database
|
||||
@@ -190,34 +211,37 @@ export async function handleInboxItems(
|
||||
const decryptedItem = await db
|
||||
.storage()
|
||||
.decryptPGPMessage(inboxKeys.privateKey, item.cipher);
|
||||
const parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
|
||||
|
||||
if (parsed.type !== "note") {
|
||||
continue;
|
||||
}
|
||||
if (parsed.version !== 1) {
|
||||
const validation = RawInboxItemSchema.safeParse(
|
||||
JSON.parse(decryptedItem)
|
||||
);
|
||||
if (!validation.success) {
|
||||
logger.warn("Failed to validate inbox item.", {
|
||||
inboxItem: item,
|
||||
errors: validation.error.issues
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = validation.data;
|
||||
await db.notes.add({
|
||||
id: item.id,
|
||||
title: parsed.title,
|
||||
favorite: parsed.favorite,
|
||||
pinned: parsed.pinned,
|
||||
readonly: parsed.readonly,
|
||||
title: data.title,
|
||||
favorite: data.favorite,
|
||||
pinned: data.pinned,
|
||||
readonly: data.readonly,
|
||||
content: {
|
||||
data: parsed?.content?.data ?? "",
|
||||
data: sanitizeHtml(data?.content?.data ?? ""),
|
||||
type: "tiptap"
|
||||
}
|
||||
});
|
||||
if (parsed.archived !== undefined) {
|
||||
await db.notes.archive(parsed.archived, item.id);
|
||||
if (data.archived !== undefined) {
|
||||
await db.notes.archive(data.archived, item.id);
|
||||
}
|
||||
for (const notebookId of parsed.notebookIds || []) {
|
||||
for (const notebookId of data.notebookIds || []) {
|
||||
if (!(await db.notebooks.exists(notebookId))) continue;
|
||||
await db.notes.addToNotebook(notebookId, item.id);
|
||||
}
|
||||
for (const tagId of parsed.tagIds || []) {
|
||||
for (const tagId of data.tagIds || []) {
|
||||
if (!(await db.tags.exists(tagId))) continue;
|
||||
await db.relations.add(
|
||||
{ type: "tag", id: tagId },
|
||||
|
||||
@@ -64,20 +64,3 @@ export type SyncInboxItem = {
|
||||
cipher: string;
|
||||
alg: string;
|
||||
};
|
||||
|
||||
export type ParsedInboxItem = {
|
||||
title: string;
|
||||
pinned?: boolean;
|
||||
favorite?: boolean;
|
||||
readonly?: boolean;
|
||||
archived?: boolean;
|
||||
notebookIds?: string[];
|
||||
tagIds?: string[];
|
||||
type: "note";
|
||||
source: string;
|
||||
version: 1;
|
||||
content?: {
|
||||
type: "html";
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
299
packages/core/src/utils/__tests__/html-parser.test.ts
Normal file
299
packages/core/src/utils/__tests__/html-parser.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
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, sanitizeHtml } 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>"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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"');
|
||||
});
|
||||
});
|
||||
117
packages/core/src/utils/dom-purify.ts
Normal file
117
packages/core/src/utils/dom-purify.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 DOMPurify, { WindowLike } from "dompurify";
|
||||
|
||||
const parseHTML = (input: string) =>
|
||||
"DOMParser" in globalThis
|
||||
? new globalThis.DOMParser().parseFromString(input, "text/html")
|
||||
: null;
|
||||
|
||||
function ensureNodeFilter(win: any) {
|
||||
if (win.NodeFilter) return;
|
||||
|
||||
function NodeFilter() {}
|
||||
|
||||
NodeFilter.FILTER_ACCEPT = 1;
|
||||
NodeFilter.FILTER_REJECT = 2;
|
||||
NodeFilter.FILTER_SKIP = 3;
|
||||
|
||||
NodeFilter.SHOW_ALL = 0xff_ff_ff_ff;
|
||||
NodeFilter.SHOW_ELEMENT = 0x1;
|
||||
NodeFilter.SHOW_ATTRIBUTE = 0x2;
|
||||
NodeFilter.SHOW_TEXT = 0x4;
|
||||
NodeFilter.SHOW_CDATA_SECTION = 0x8;
|
||||
NodeFilter.SHOW_ENTITY_REFERENCE = 0x10;
|
||||
NodeFilter.SHOW_ENTITY = 0x20;
|
||||
NodeFilter.SHOW_PROCESSING_INSTRUCTION = 0x40;
|
||||
NodeFilter.SHOW_COMMENT = 0x80;
|
||||
NodeFilter.SHOW_DOCUMENT = 0x1_00;
|
||||
NodeFilter.SHOW_DOCUMENT_TYPE = 0x2_00;
|
||||
NodeFilter.SHOW_DOCUMENT_FRAGMENT = 0x4_00;
|
||||
NodeFilter.SHOW_NOTATION = 0x8_00;
|
||||
|
||||
win.NodeFilter = NodeFilter;
|
||||
}
|
||||
|
||||
function createPurifyWindow() {
|
||||
const document = parseHTML(
|
||||
"<!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(...)
|
||||
if (!win.DOMParser) {
|
||||
win.DOMParser = DOMParser;
|
||||
}
|
||||
|
||||
ensureNodeFilter(win);
|
||||
|
||||
// DOMPurify checks existence AND later relies on callable behavior.
|
||||
if (!doc.implementation) {
|
||||
doc.implementation = {};
|
||||
}
|
||||
|
||||
if (typeof doc.implementation.createHTMLDocument !== "function") {
|
||||
doc.implementation.createHTMLDocument = (title = "") => {
|
||||
const document = parseHTML(
|
||||
"<!doctype html><html><head><title>dompurify</title></head><body></body></html>"
|
||||
);
|
||||
return document;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof doc.implementation.createDocument !== "function") {
|
||||
doc.implementation.createDocument = () => {
|
||||
// Keep this as HTML doc because DOMPurify asks for body/html via getElementsByTagName.
|
||||
return parseHTML(
|
||||
"<!doctype html><html><head></head><body></body></html>"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return win as WindowLike;
|
||||
}
|
||||
|
||||
let domPurify: DOMPurify.DOMPurify | undefined = undefined;
|
||||
function getDomPurify() {
|
||||
if (!domPurify) {
|
||||
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;
|
||||
}
|
||||
|
||||
export { getDomPurify };
|
||||
@@ -17,8 +17,9 @@ 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";
|
||||
|
||||
export const parseHTML = (input: string) =>
|
||||
"DOMParser" in globalThis
|
||||
@@ -28,6 +29,14 @@ export const parseHTML = (input: string) =>
|
||||
)
|
||||
: null;
|
||||
|
||||
export const sanitizeHtml = (html: string): string => {
|
||||
const inputHtml = normalizeToHtmlBody(html);
|
||||
return getDomPurify().sanitize(inputHtml, {
|
||||
RETURN_DOM: false,
|
||||
ADD_TAGS: ["iframe"]
|
||||
}) as string;
|
||||
};
|
||||
|
||||
export function getDummyDocument() {
|
||||
const doc = parseHTML("<div></div>");
|
||||
return doc;
|
||||
@@ -44,6 +53,166 @@ 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;
|
||||
|
||||
// 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 = 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());
|
||||
|
||||
// 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, 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) {
|
||||
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();
|
||||
// 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) {
|
||||
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