Files
NoteAI/src/lib/sanitize-html.ts
T
2026-02-24 11:33:08 +08:00

131 lines
3.1 KiB
TypeScript

const ALLOWED_TAGS = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
]);
const DROP_CONTENT_TAGS = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]);
const GLOBAL_ALLOWED_ATTRS = new Set(["class"]);
const TAG_ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(["href", "title", "target", "rel"]),
code: new Set(["class"]),
span: new Set(["class"]),
};
function isSafeUrl(value: string): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
if (
normalized.startsWith("http://") ||
normalized.startsWith("https://") ||
normalized.startsWith("mailto:") ||
normalized.startsWith("tel:") ||
normalized.startsWith("/") ||
normalized.startsWith("#")
) {
return true;
}
return false;
}
function sanitizeAttributes(element: Element): void {
const tagName = element.tagName.toLowerCase();
const allowedForTag = TAG_ALLOWED_ATTRS[tagName] || new Set<string>();
const attrs = Array.from(element.attributes);
for (const attr of attrs) {
const name = attr.name.toLowerCase();
const value = attr.value;
const allowed = GLOBAL_ALLOWED_ATTRS.has(name) || allowedForTag.has(name);
if (!allowed) {
element.removeAttribute(attr.name);
continue;
}
if ((name === "href" || name === "src") && !isSafeUrl(value)) {
element.removeAttribute(attr.name);
}
}
if (tagName === "a") {
if (element.getAttribute("target") === "_blank") {
element.setAttribute("rel", "noopener noreferrer nofollow");
} else {
element.removeAttribute("target");
}
}
}
function sanitizeNode(node: Node): void {
if (node.nodeType === Node.TEXT_NODE) return;
if (node.nodeType !== Node.ELEMENT_NODE) {
node.parentNode?.removeChild(node);
return;
}
const element = node as Element;
const tagName = element.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tagName)) {
if (DROP_CONTENT_TAGS.has(tagName)) {
element.remove();
return;
}
const parent = element.parentNode;
if (!parent) return;
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
return;
}
sanitizeAttributes(element);
const children = Array.from(element.childNodes);
for (const child of children) {
sanitizeNode(child);
}
}
export function sanitizeHtml(input: string): string {
if (typeof window === "undefined") return input;
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const nodes = Array.from(doc.body.childNodes);
for (const node of nodes) {
sanitizeNode(node);
}
return doc.body.innerHTML;
}