131 lines
3.1 KiB
TypeScript
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;
|
|
}
|