Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/html-sanitizer.ts
T

221 lines
8.4 KiB
TypeScript

import { Marked } from "marked";
const removedElementNames = ["script", "style", "iframe", "object", "embed", "link", "meta", "base", "svg"];
const removedElementsSelector = removedElementNames.join(",");
const urlAttributeNames = new Set(["href", "src", "xlink:href", "action", "formaction", "poster", "background"]);
const blockedUrlProtocols = ["javascript:", "vbscript:", "data:"];
const anchorRelTokens = ["noopener", "noreferrer"];
// Render markdown to HTML with raw embedded HTML ESCAPED rather than passed
// through. marked is not a security sanitizer: by default it emits author-typed
// block/inline HTML verbatim, which is the actual stored-XSS vector (e.g.
// `<img src="x"/onerror=...>`). Escaping raw HTML at the marked layer neutralizes
// tag injection regardless of which downstream sanitizer path runs (the server
// SSR string fallback below does not use a real HTML parser). sanitizeMarkedHtml
// is still chained afterwards to strip unsafe URL protocols (e.g.
// `[x](javascript:...)`) from marked-generated links and to add rel hardening.
function escapeRawHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const markdownRenderer = new Marked({
renderer: {
html: (token) => escapeRawHtml(typeof token === "string" ? token : token?.text ?? token?.raw ?? ""),
},
});
export function renderSanitizedMarkdown(markdown: string | null | undefined) {
return sanitizeMarkedHtml(markdownRenderer.parse(markdown ?? "", { async: false }) as string);
}
export function sanitizeMarkedHtml(html: string | null | undefined) {
const unsafeHtml = html ?? "";
if (typeof DOMParser !== "undefined") {
return sanitizeWithDomParser(unsafeHtml);
}
return sanitizeWithStringFallback(unsafeHtml);
}
// Inline SVG icons are stored as raw markup and injected with
// dangerouslySetInnerHTML, so they must be sanitized before render. Scriptable
// SVG (event handlers, <script>, <foreignObject>, animation elements that can
// retarget attributes, javascript: hrefs) is stripped while the drawing
// structure is preserved. `className` is developer-supplied (never the stored
// value) and is injected onto the root <svg> after sanitization.
const svgDangerousElementNames = [
"script",
"foreignObject",
"style",
"animate",
"animateTransform",
"animateMotion",
"set",
"handler",
"listener",
"iframe",
"object",
"embed",
"link",
"meta",
"base",
];
export function sanitizeIconSvg(svg: string | null | undefined, className?: string) {
const input = svg ?? "";
if (!input.trim().toLowerCase().startsWith("<svg")) {
return "";
}
const sanitized = typeof DOMParser !== "undefined" ? sanitizeSvgWithDomParser(input) : sanitizeSvgWithStringFallback(input);
if (!className) {
return sanitized;
}
const safeClassName = className.replace(/["'<>]/g, "");
return sanitized.replace(/<svg\b/i, `<svg class="${safeClassName}"`);
}
function sanitizeSvgWithDomParser(svg: string) {
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
if (parsed.getElementsByTagName("parsererror").length > 0) {
return sanitizeSvgWithStringFallback(svg);
}
const root = parsed.documentElement;
if (!root || root.nodeName.toLowerCase() !== "svg") {
return sanitizeSvgWithStringFallback(svg);
}
const dangerousSelector = svgDangerousElementNames.join(",");
root.querySelectorAll(dangerousSelector).forEach((element) => element.remove());
removeUnsafeAttributes(root);
root.querySelectorAll("*").forEach((element) => removeUnsafeAttributes(element));
return new XMLSerializer().serializeToString(root);
}
function sanitizeSvgWithStringFallback(svg: string) {
let sanitized = svg;
svgDangerousElementNames.forEach((elementName) => {
sanitized = sanitized.replace(new RegExp(`<\\s*${elementName}\\b[^>]*>[\\s\\S]*?<\\s*/\\s*${elementName}\\s*>`, "gi"), "");
sanitized = sanitized.replace(new RegExp(`<\\s*/?\\s*${elementName}\\b[^>]*>`, "gi"), "");
});
return stripUnsafeAttributesFromString(sanitized);
}
function sanitizeWithDomParser(html: string) {
const document = new DOMParser().parseFromString(html, "text/html");
document.querySelectorAll(removedElementsSelector).forEach((element) => element.remove());
document.body.querySelectorAll("*").forEach((element) => {
removeUnsafeAttributes(element);
if (element.tagName.toLowerCase() === "a") {
addExternalAnchorRel(element);
}
});
return document.body.innerHTML;
}
function removeUnsafeAttributes(element: Element) {
Array.from(element.attributes).forEach((attribute) => {
const attributeName = attribute.name.toLowerCase();
if (attributeName.startsWith("on") || attributeName === "style" || attributeName === "srcdoc") {
element.removeAttribute(attribute.name);
return;
}
if (urlAttributeNames.has(attributeName) && isUnsafeUrl(attribute.value)) {
element.removeAttribute(attribute.name);
}
});
}
function addExternalAnchorRel(element: Element) {
const href = element.getAttribute("href");
if (!href || !isExternalHttpUrl(href)) {
return;
}
const relTokens = new Set(
(element.getAttribute("rel") ?? "")
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean)
);
anchorRelTokens.forEach((token) => relTokens.add(token));
element.setAttribute("rel", Array.from(relTokens).join(" "));
}
// Attributes can legally be separated by whitespace OR a forward slash in HTML
// (e.g. `<img src="x"/onerror=...>`), so match either as the boundary; anchoring
// on `\s` alone left slash-delimited event handlers intact in this server path.
function stripUnsafeAttributesFromString(html: string) {
let sanitized = html;
sanitized = sanitized.replace(/[\s/](?:on[\w:-]+|style|srcdoc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, "");
sanitized = sanitized.replace(/[\s/](?:on[\w:-]+|style|srcdoc)(?=[\s/]|>|$)/gi, "");
sanitized = sanitized.replace(
/[\s/](href|src|xlink:href|action|formaction|poster|background)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi,
(match, _name, rawValue) => {
const value = rawValue.slice(0, 1) === `"` || rawValue.slice(0, 1) === `'` ? rawValue.slice(1, -1) : rawValue;
return isUnsafeUrl(value) ? "" : match;
}
);
return sanitized;
}
function sanitizeWithStringFallback(html: string) {
let sanitized = html;
removedElementNames.forEach((elementName) => {
sanitized = sanitized.replace(new RegExp(`<\\s*${elementName}\\b[^>]*>[\\s\\S]*?<\\s*/\\s*${elementName}\\s*>`, "gi"), "");
sanitized = sanitized.replace(new RegExp(`<\\s*/?\\s*${elementName}\\b[^>]*>`, "gi"), "");
});
sanitized = stripUnsafeAttributesFromString(sanitized);
return sanitized.replace(/<a\b([^>]*)>/gi, (match, attributes) => {
const href = getAttributeValue(attributes, "href");
if (!href || !isExternalHttpUrl(href)) {
return match;
}
const rel = getAttributeValue(attributes, "rel");
const relTokens = new Set((rel ?? "").split(/\s+/).filter(Boolean));
anchorRelTokens.forEach((token) => relTokens.add(token));
const nextRel = Array.from(relTokens).join(" ");
if (rel !== null) {
return match.replace(/\srel\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/i, ` rel="${nextRel}"`);
}
return `<a${attributes} rel="${nextRel}">`;
});
}
function getAttributeValue(attributes: string, attributeName: string) {
const pattern = new RegExp(`\\s${attributeName}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s"'=<>` + "`" + `]+)`, "i");
const match = attributes.match(pattern);
if (!match) {
return null;
}
const rawValue = match[1];
return rawValue.slice(0, 1) === `"` || rawValue.slice(0, 1) === `'` ? rawValue.slice(1, -1) : rawValue;
}
function isUnsafeUrl(value: string) {
const normalized = normalizeUrlScheme(value);
return blockedUrlProtocols.some((protocol) => normalized.startsWith(protocol));
}
function isExternalHttpUrl(value: string) {
const normalized = normalizeUrlScheme(value);
return normalized.startsWith("http://") || normalized.startsWith("https://");
}
function normalizeUrlScheme(value: string) {
return decodeHtmlEntitiesForUrl(value)
.trim()
.replace(/[\u0000-\u001f\u007f\s]+/g, "")
.toLowerCase();
}
function decodeHtmlEntitiesForUrl(value: string) {
return value
.replace(/&colon;/gi, ":")
.replace(/&#x([0-9a-f]+);?/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
.replace(/&#([0-9]+);?/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 10)));
}