@blocklet/xss
Version:
blocklet prevent xss attack
209 lines (208 loc) • 6.22 kB
JavaScript
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;
};