UNPKG

@blocklet/xss

Version:

blocklet prevent xss attack

209 lines (208 loc) 6.22 kB
import * as xss from "xss"; import { filterXSS } from "xss"; import omit from "lodash/omit"; import path from "path"; const ignoreTagList = [ // here is a blacklist "script", "img", "iframe", "body", "form", "style", "link", "meta", "bgsound", "svg", "embed", "object", "video", "audio", "source", "track", "marquee", "blink", "noscript", "param", "textarea", "input", "select", "button" ]; const ignoreTagMap = ignoreTagList.reduce((acc, item) => { acc[item] = true; return acc; }, {}); let defaultOptions = { escapeHtml: (str) => str, whiteList: omit(xss.getDefaultWhiteList(), ignoreTagList), onIgnoreTag: function(tag, html, options) { if (ignoreTagMap[tag]) { return ""; } }, onTagAttr: (tag, html) => { if (tag === "!BACKUP") { return html; } }, stripIgnoreTagBody: ["script"] }; function advanceProcess(html, options) { while (html !== filterXSS(html, options)) { html = filterXSS(html, options); } return html; } export const initSanitize = (_options = {}) => { const options = { ...defaultOptions, ..._options }; const sanitize = (data) => { if (typeof data === "string") { return advanceProcess(data, options); } if (Array.isArray(data)) { return data.map((item) => { if (typeof item === "string") { return advanceProcess(item, options); } if (Array.isArray(item) || typeof item === "object") { return sanitize(item); } return item; }); } if (typeof data === "object" && data !== null) { Object.keys(data).forEach((key) => { if (options?.allowedKeys?.includes(key)) { return; } const item = data[key]; if (typeof item === "string") { data[key] = advanceProcess(item, options); } else if (Array.isArray(item) || typeof item === "object") { data[key] = sanitize(item); } }); } return data; }; return sanitize; }; function preserveTagCase(content) { const tagCaseMapping = {}; Object.keys(svgWhiteList).forEach((originalTag) => { const lowerCaseTag = originalTag.toLowerCase(); if (lowerCaseTag !== originalTag) { tagCaseMapping[lowerCaseTag] = originalTag; } }); let result = content; Object.entries(tagCaseMapping).forEach(([lowercase, original]) => { result = result.replace(new RegExp(`<${lowercase}([^>]*)>`, "gi"), (_match, rest) => `<${original}${rest}>`); result = result.replace(new RegExp(`</${lowercase}>`, "gi"), `</${original}>`); }); return result; } function preserveAttrCase(tag, name, value, isWhiteAttr) { if (isWhiteAttr) { const originalTagKey = Object.keys(svgWhiteList).find((key) => key.toLowerCase() === tag.toLowerCase()); if (originalTagKey) { const originalAttrName = svgWhiteList[originalTagKey].find( (attr) => attr.toLowerCase() === name.toLowerCase() ); if (originalAttrName) { value = xss.safeAttrValue(tag, name, value); if (value) { return originalAttrName + '="' + value + '"'; } else { return originalAttrName; } } } } } export const svgWhiteList = { svg: ["width", "height", "viewBox", "xmlns", "version", "preserveAspectRatio", "xml:space"], circle: ["cx", "cy", "r", "fill", "stroke", "stroke-width", "fill-opacity", "stroke-opacity"], ellipse: ["cx", "cy", "rx", "ry", "fill", "stroke", "stroke-width"], line: ["x1", "y1", "x2", "y2", "stroke", "stroke-width"], path: ["d", "fill", "stroke", "stroke-width", "fill-rule", "stroke-linecap", "stroke-linejoin"], polygon: ["points", "fill", "stroke", "stroke-width"], polyline: ["points", "fill", "stroke", "stroke-width"], rect: ["x", "y", "width", "height", "rx", "ry", "fill", "stroke", "stroke-width"], g: ["transform", "fill", "stroke"], text: ["x", "y", "font-size", "font-family", "text-anchor", "fill"], defs: [], clipPath: ["id"], mask: ["id"], use: ["x", "y", "width", "height"], linearGradient: ["id", "x1", "y1", "x2", "y2", "gradientUnits"], radialGradient: ["id", "cx", "cy", "r", "fx", "fy", "gradientUnits"], stop: ["offset", "stop-color", "stop-opacity"], pattern: ["id", "width", "height", "patternUnits", "patternTransform"] }; const svgSanitizeOptions = { whiteList: svgWhiteList, stripIgnoreTagBody: ["script", "style"], onIgnoreTag: function(tag, html, options) { return ""; }, onIgnoreTagAttr: function(tag, name, value, isWhiteAttr) { if (name.startsWith("on") || name === "href" || name === "xlink:href") { return ""; } if (name === "style") { const safeValue = value.replace(/expression\(.*\)|javascript:|data:|@import|behavior|binding|moz-binding/gi, ""); if (safeValue !== value) { return ""; } return `${name}="${safeValue}"`; } if (tag === "use" && (name === "href" || name === "xlink:href")) { if (value.startsWith("#") && !/[<>"']/.test(value)) { return `${name}="${value}"`; } return ""; } if (name === "id" || name === "class") { return `${name}="${value}"`; } } }; export const sanitizeSvg = (svgContent, options, svgOptions) => { const isSvg = isSvgFile(svgContent); if (!isSvg) { throw new Error("Invalid SVG content"); } const filterOptions = { ...svgSanitizeOptions, ...svgOptions || {} }; if (options?.preserveCase) { filterOptions.onTagAttr = preserveAttrCase; } const processedContent = advanceProcess(svgContent, filterOptions); return options?.preserveCase ? preserveTagCase(processedContent) : processedContent; }; export const isSvgFile = (svgContent, file) => { if (typeof svgContent !== "string") { return false; } const svgRegex = /<svg[^>]*?(?:>|\/>)|<\?xml[^>]*>\s*<svg[^>]*?(?:>|\/?>)/i; const isSvg = svgRegex.test(svgContent); if (!isSvg) { return false; } if (file?.name) { const ext = path.extname(file.name).toLowerCase(); if (ext !== ".svg") { return false; } } if (file?.type) { if (!file.type.toLowerCase().includes("image/svg")) { return false; } } return true; };