@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
482 lines (481 loc) • 16 kB
JavaScript
/**
* SVG Sanitization Utilities
* OWASP-compliant SVG sanitization using allowlist approach
*
* This module addresses:
* - Script tag injection
* - Event handler injection (onload, onerror, etc.)
* - javascript: URL schemes
* - CSS-based XSS (expression(), url(), -moz-binding)
* - SMIL animation attacks
* - foreignObject-based HTML injection
* - External reference attacks (use, image elements)
* - XXE via DOCTYPE/ENTITY declarations
*
* Uses regex-based approach for robustness without external dependencies.
*
* @see https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
*/
/**
* Safe SVG elements (allowlist)
* Only these elements will be preserved in sanitized output
*/
const SAFE_SVG_ELEMENTS = new Set([
// Container elements
"svg",
"g",
"defs",
"symbol",
"marker",
// Basic shapes
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"path",
// Text
"text",
"tspan",
// Gradients and patterns
"linearGradient",
"radialGradient",
"stop",
"pattern",
// Clipping and masking
"clipPath",
"mask",
// Filters (limited - no custom filters that could reference external resources)
"filter",
"feGaussianBlur",
"feOffset",
"feBlend",
"feColorMatrix",
"feMerge",
"feMergeNode",
// Metadata (safe)
"title",
"desc",
"metadata",
]);
/**
* Dangerous SVG elements (blocklist)
* These elements are explicitly blocked due to XSS risks
*/
const DANGEROUS_SVG_ELEMENTS = new Set([
"script", // Direct script execution
"style", // Can contain CSS with XSS vectors
"foreignObject", // Can contain HTML with XSS
"use", // Can reference external SVG with malicious content
"image", // Can reference external resources or data: URLs
"a", // Can have href with javascript:
"animate", // SMIL animations can set dangerous attributes
"set", // SMIL - can set href to javascript:
"animateMotion", // SMIL animation
"animateTransform", // SMIL animation
"animateColor", // SMIL animation (deprecated but still works in some browsers)
"iframe", // Can embed external content
"object", // Can embed external content
"embed", // Can embed external content
]);
/**
* Safe SVG attributes (allowlist)
*/
const SAFE_SVG_ATTRIBUTES = new Set([
// Core attributes
"id",
"class",
// Geometry
"x",
"y",
"width",
"height",
"cx",
"cy",
"r",
"rx",
"ry",
"d",
"points",
"x1",
"y1",
"x2",
"y2",
// Appearance (limited - style is intentionally excluded)
"fill",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap",
"stroke-linejoin",
"opacity",
"fill-opacity",
"stroke-opacity",
"fill-rule",
// Transform
"transform",
// SVG-specific
"viewBox",
"xmlns",
"xmlns:xlink",
"preserveAspectRatio",
"version",
// Gradient/Pattern
"offset",
"stop-color",
"stop-opacity",
"gradientUnits",
"gradientTransform",
"patternUnits",
"patternTransform",
"spreadMethod",
// Text
"font-family",
"font-size",
"font-weight",
"font-style",
"text-anchor",
"dominant-baseline",
"alignment-baseline",
"letter-spacing",
// Filter
"stdDeviation",
"dx",
"dy",
"in",
"in2",
"result",
"mode",
"type",
"values",
// Clip/Mask
"clipPathUnits",
"maskUnits",
"maskContentUnits",
// Marker
"markerWidth",
"markerHeight",
"refX",
"refY",
"orient",
"markerUnits",
]);
/**
* Dangerous SVG attributes (blocklist)
* These are explicitly blocked even if they appear on safe elements
*/
const DANGEROUS_SVG_ATTRIBUTES = new Set([
"style", // Can contain CSS with expression(), url(), -moz-binding
"href", // Can contain javascript:
"xlink:href", // Can contain javascript:
// Event handlers (comprehensive list)
"onload",
"onerror",
"onclick",
"onmouseover",
"onmouseout",
"onmousedown",
"onmouseup",
"onmousemove",
"onfocus",
"onblur",
"onabort",
"onbegin",
"onend",
"onrepeat",
"onactivate",
"onscroll",
"onresize",
"onzoom",
"oninput",
"onchange",
"onsubmit",
"onreset",
"onkeydown",
"onkeyup",
"onkeypress",
"ondrag",
"ondragstart",
"ondragend",
"ondragenter",
"ondragleave",
"ondragover",
"ondrop",
// SVG-specific event handlers
"onunload",
"oncopy",
"oncut",
"onpaste",
]);
/**
* Sanitize SVG content by removing dangerous elements and attributes.
* Uses OWASP-compliant allowlist approach with regex-based parsing.
*
* @param svgContent - Raw SVG content to sanitize
* @returns Sanitized SVG content
* @throws Error if SVG content is invalid or contains XXE declarations
*
* @example
* const malicious = '<svg><script>alert(1)</script></svg>';
* const safe = sanitizeSvgContent(malicious); // '<svg></svg>'
*
* @example
* const xss = '<svg onload="alert(1)"><rect fill="red"/></svg>';
* const safe = sanitizeSvgContent(xss); // '<svg><rect fill="red"/></svg>'
*/
export function sanitizeSvgContent(svgContent) {
const result = sanitizeSvgContentDetailed(svgContent);
return result.content;
}
/**
* Sanitize SVG content with detailed information about what was removed.
* Useful for logging and security auditing.
*
* @param svgContent - Raw SVG content to sanitize
* @returns Detailed sanitization result with removed items
* @throws Error if SVG content is invalid or contains XXE declarations
*/
export function sanitizeSvgContentDetailed(svgContent) {
if (!svgContent || typeof svgContent !== "string") {
throw new Error("SVG content is required and must be a string");
}
const removedItems = [];
let content = svgContent;
const originalContent = svgContent;
// 1. Block DOCTYPE and ENTITY declarations (XXE prevention)
if (content.includes("<!DOCTYPE") || content.includes("<!ENTITY")) {
throw new Error("SVG contains DOCTYPE or ENTITY declarations which are not allowed for security reasons");
}
// 2. Remove XML stylesheet processing instructions
const stylesheetRegex = /<\?xml-stylesheet[^?]*\?>/gi;
if (stylesheetRegex.test(content)) {
removedItems.push("XML stylesheet processing instruction");
content = content.replace(stylesheetRegex, "");
}
// 3. Remove CDATA sections that might contain malicious content
const cdataRegex = /<!\[CDATA\[[\s\S]*?\]\]>/gi;
const cdataMatches = content.match(cdataRegex);
if (cdataMatches) {
removedItems.push(`CDATA sections (${cdataMatches.length} found)`);
content = content.replace(cdataRegex, "");
}
// 4. Remove all dangerous elements with their content
const dangerousElements = Array.from(DANGEROUS_SVG_ELEMENTS);
for (let i = 0; i < dangerousElements.length; i++) {
const element = dangerousElements[i];
// Match both self-closing and paired tags
const pairedRegex = new RegExp(`<${element}[^>]*>[\\s\\S]*?<\\/${element}>`, "gi");
const selfClosingRegex = new RegExp(`<${element}[^>]*\\/?>`, "gi");
if (pairedRegex.test(content)) {
removedItems.push(`Element: <${element}> (with content)`);
content = content.replace(pairedRegex, "");
}
if (selfClosingRegex.test(content)) {
removedItems.push(`Element: <${element}>`);
content = content.replace(selfClosingRegex, "");
}
}
// 5. Remove unknown elements (not in safe list)
// Match element tags and check against allowlist
const elementRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g;
content = content.replace(elementRegex, (match, tagName) => {
const lowerTagName = tagName.toLowerCase();
// Skip XML declaration
if (lowerTagName === "xml") {
return match;
}
// Check if it's a safe element
if (!SAFE_SVG_ELEMENTS.has(tagName) &&
!SAFE_SVG_ELEMENTS.has(lowerTagName)) {
// Check case-insensitive match for camelCase elements like linearGradient
const isSafe = Array.from(SAFE_SVG_ELEMENTS).some((safe) => safe.toLowerCase() === lowerTagName);
if (!isSafe) {
removedItems.push(`Unknown element: <${tagName}>`);
return "";
}
}
return match;
});
// 6. Remove dangerous attributes from remaining elements
content = removeDangerousAttributes(content, removedItems);
// 7. Remove javascript: URLs from any remaining attribute values
const jsUrlRegex = /(?:=\s*["']?)javascript:[^"'\s>]*/gi;
if (jsUrlRegex.test(content)) {
removedItems.push("javascript: URL scheme");
content = content.replace(jsUrlRegex, '=""');
}
// 8. Remove data: URLs (except for safe image types)
const dataUrlRegex = /(?:=\s*["']?)data:(?!image\/(?:png|jpeg|jpg|gif|svg\+xml))[^"'\s>]*/gi;
if (dataUrlRegex.test(content)) {
removedItems.push("Suspicious data: URL");
content = content.replace(dataUrlRegex, '=""');
}
// 9. Remove vbscript: URLs
const vbscriptRegex = /(?:=\s*["']?)vbscript:[^"'\s>]*/gi;
if (vbscriptRegex.test(content)) {
removedItems.push("vbscript: URL scheme");
content = content.replace(vbscriptRegex, '=""');
}
// 10. Clean up any empty elements left behind
content = content.replace(/\s+/g, " ").trim();
return {
content,
removedItems: Array.from(new Set(removedItems)), // Deduplicate
wasModified: content !== originalContent,
};
}
/**
* Remove dangerous attributes from SVG elements.
* Keeps only attributes in the safe allowlist.
*/
function removeDangerousAttributes(content, removedItems) {
// Match elements with attributes
const elementWithAttrsRegex = /<([a-zA-Z][a-zA-Z0-9]*)([^>]*)>/g;
return content.replace(elementWithAttrsRegex, (match, tagName, attrs) => {
if (!attrs || !attrs.trim()) {
return match;
}
// Parse attributes
const attrRegex = /([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
const safeAttrs = [];
for (let attrMatch = attrRegex.exec(attrs); attrMatch !== null; attrMatch = attrRegex.exec(attrs)) {
const attrName = attrMatch[1];
const attrValue = attrMatch[2] ?? attrMatch[3] ?? "";
const lowerAttrName = attrName.toLowerCase();
// Check if attribute is explicitly dangerous
if (DANGEROUS_SVG_ATTRIBUTES.has(lowerAttrName)) {
removedItems.push(`Attribute: ${attrName}`);
continue;
}
// Check if attribute starts with 'on' (event handler pattern)
if (lowerAttrName.startsWith("on")) {
removedItems.push(`Event handler: ${attrName}`);
continue;
}
// Check if attribute is in safe list
if (!SAFE_SVG_ATTRIBUTES.has(attrName) &&
!SAFE_SVG_ATTRIBUTES.has(lowerAttrName)) {
// Check case-insensitive match for hyphenated attributes
const isSafe = Array.from(SAFE_SVG_ATTRIBUTES).some((safe) => safe.toLowerCase() === lowerAttrName);
if (!isSafe) {
removedItems.push(`Unknown attribute: ${attrName}`);
continue;
}
}
// Validate attribute value
const lowerValue = attrValue.toLowerCase();
// Block javascript: URLs
if (lowerValue.includes("javascript:")) {
removedItems.push(`javascript: URL in ${attrName}`);
continue;
}
// Block suspicious data: URLs (allow safe image types)
if (lowerValue.startsWith("data:") &&
!lowerValue.startsWith("data:image/png") &&
!lowerValue.startsWith("data:image/jpeg") &&
!lowerValue.startsWith("data:image/jpg") &&
!lowerValue.startsWith("data:image/gif") &&
!lowerValue.startsWith("data:image/svg+xml")) {
removedItems.push(`Suspicious data: URL in ${attrName}`);
continue;
}
// Block expression() and other CSS XSS vectors in values
if (lowerValue.includes("expression(") ||
lowerValue.includes("-moz-binding") ||
lowerValue.includes("behavior:")) {
removedItems.push(`CSS XSS vector in ${attrName}`);
continue;
}
// Attribute is safe, keep it
safeAttrs.push(`${attrName}="${escapeAttributeValue(attrValue)}"`);
}
// Also keep standalone attributes (like xmlns without value in some cases)
const standaloneAttrRegex = /\s([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|>|$|\/)/g;
let standaloneMatch = standaloneAttrRegex.exec(attrs);
let iterations = 0;
const MAX_ITERATIONS = 1000;
while (standaloneMatch !== null && iterations++ < MAX_ITERATIONS) {
const attrName = standaloneMatch[1];
// Only keep if it looks like a valid attribute and is safe
if (SAFE_SVG_ATTRIBUTES.has(attrName) ||
SAFE_SVG_ATTRIBUTES.has(attrName.toLowerCase())) {
// Avoid duplicates
if (!safeAttrs.some((a) => a.startsWith(`${attrName}=`))) {
safeAttrs.push(attrName);
}
}
standaloneMatch = standaloneAttrRegex.exec(attrs);
}
if (safeAttrs.length > 0) {
return `<${tagName} ${safeAttrs.join(" ")}>`;
}
return `<${tagName}>`;
});
}
/**
* Escape attribute value to prevent injection
*/
function escapeAttributeValue(value) {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
}
/**
* Check if SVG content appears to be safe (quick validation).
* Does NOT sanitize - use sanitizeSvgContent for that.
*
* @param svgContent - SVG content to check
* @returns true if content appears safe, false if it contains suspicious patterns
*/
export function isSvgContentSafe(svgContent) {
if (!svgContent || typeof svgContent !== "string") {
return false;
}
const lowerContent = svgContent.toLowerCase();
// Check for XXE
if (lowerContent.includes("<!doctype") || lowerContent.includes("<!entity")) {
return false;
}
// Check for dangerous elements
const dangerousElementsArray = Array.from(DANGEROUS_SVG_ELEMENTS);
for (let i = 0; i < dangerousElementsArray.length; i++) {
const element = dangerousElementsArray[i];
if (lowerContent.includes(`<${element.toLowerCase()}`)) {
return false;
}
}
// Check for event handlers
if (/\bon[a-z]+\s*=/i.test(svgContent)) {
return false;
}
// Check for javascript: URLs
if (lowerContent.includes("javascript:")) {
return false;
}
return true;
}
/**
* Legacy alias for sanitizeSvgContent.
* Maintained for backward compatibility.
*
* @param svgContent - Raw SVG content
* @returns Sanitized SVG content
*/
export function sanitizeSvg(svgContent) {
return sanitizeSvgContent(svgContent);
}
/**
* Get lists of safe and dangerous elements/attributes for reference.
* Useful for documentation and debugging.
*/
export function getSvgSanitizationRules() {
return {
safeElements: Array.from(SAFE_SVG_ELEMENTS),
dangerousElements: Array.from(DANGEROUS_SVG_ELEMENTS),
safeAttributes: Array.from(SAFE_SVG_ATTRIBUTES),
dangerousAttributes: Array.from(DANGEROUS_SVG_ATTRIBUTES),
};
}