Problem
When i try to upload the below SVG, it consumes all memory and eventually main thread freezes and window snap happens.
Steps to reproduce
Run the below snippet and you will see it will snap the result panel, or you can try creating a svg
file of this and upload it then also the same thing happens
What I know
This is basically a security threat altogether, commonly refer as SVG Billion Laugh Attack
when user uploads such a malicious file and which can eventually consumes browser's huge memory.
Approach to solve
I want to stop such file uploads, which i think is possible if i can recognise such big SVG uploads, someway can track if it's consuming a huge memory say any specific limitation and if that limitation get violated, I can simply stop the user from uploading it.
Thanks in advance
<svg version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" xml:space="preserve">
<path id="a" d="M0,0"/>
<g id="b"><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/></g>
<g id="c"><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/></g>
<g id="d"><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/></g>
<g id="e"><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/></g>
<g id="f"><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/></g>
<g id="g"><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/></g>
<g id="h"><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/></g>
<g id="i"><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/></g>
<g id="j"><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/></g>
</svg>
Probably, not the most elegant way, but since svg is based on xml,
we can query for potentially malicious code before uploading by analyzing the file input data on:
[!entity]
new DOMParser().parseFromString(markup, "image/svg+xml")
to query potentially malicious or undesired elements (e.g. <script>
tags)The used analyzeSVG(markup, allowed)
helper function checks element occurrences like:
<use>
elements<use>
instancesScroll down to test file uploads - check the console output for detailed feedback.
function validateSVG(markup, allowed = {}) {
// set defaults
if (!Object.keys(allowed).length) {
allowed = {
useElsNested: 100,
nonsensePaths: 0,
hasScripts: false,
hasEntity: false,
fileSizeKB: 500,
isSymbolSprite: false,
isSvgFont: false
}
}
let fileReport = analyzeSVG(markup, allowed);
let isValid = true;
let log = [];
if (!fileReport.totalEls) {
log.push('no elements')
isValid = false;
}
if (Object.keys(fileReport).length) {
if (fileReport.isBillionLaugh === true) {
log.push(`suspicious: might contain billion laugh attack`)
isValid = false;
}
for (let key in allowed) {
let val = allowed[key];
let valRep = fileReport[key];
if (typeof val === 'number' && valRep > val) {
log.push(`allowed "${key}" exceeded: ${valRep} / ${val} `)
isValid = false;
}
if (valRep === true && val === false) {
log.push(`not allowed: "${key}" `)
isValid = false;
}
}
} else {
isValid = false;
}
if (!isValid) {
log = ['SVG not valid'].concat(log);
console.log(log.join('\n'));
if (Object.keys(fileReport).length) {
console.log(fileReport);
}
}
return isValid
}
function analyzeSVG(markup, allowed = {}) {
let doc, svg;
let fileReport = {};
let maxNested = allowed.useElsNested ? allowed.useElsNested : 3000;
/**
* analyze nestes use references
*/
const countUseRefs = (useEls, maxNested = 200) => {
let nestedCount = 0;
//stop loop if number of nested use references is exceeded
for (let i = 0; i < useEls.length && nestedCount < maxNested; i++) {
let use = useEls[i];
let refId = use.getAttribute("xlink:href") ?
use.getAttribute("xlink:href") :
use.getAttribute("href");
refId = refId ? refId.replace("#", "") : "";
//normalize href attributes to facilitate JS selection
use.setAttribute("href", "#" + refId);
let refEl = svg.getElementById(refId);
let nestedUse = refEl.querySelectorAll("use");
let nestedUseLength = nestedUse.length;
nestedCount += nestedUseLength;
// query nested use references
for (let n = 0; n < nestedUse.length && nestedCount < maxNested; n++) {
let nested = nestedUse[n];
let id1 = nested.getAttribute("href").replace("#", "");
let refEl1 = svg.getElementById(id1);
let nestedUse1 = refEl1.querySelectorAll("use");
nestedCount += nestedUse1.length;
}
}
fileReport.useElsNested = nestedCount;
return nestedCount;
};
/**
* check on raw text level
*/
let hasPrologue = /\<\?xml.+\?\>|\<\!DOCTYPE.+]\>/g.test(markup);
let hasEntity = /\<\!ENTITY/gi.test(markup);
// Contains xml entity definition: highly suspicious - stop parsing!
if (allowed.hasEntity === false && hasEntity) {
fileReport.hasEntity = true;
return fileReport;
}
/**
* sanitizing for parsing:
* remove xml prologue and comments
*/
markup = markup
.replace(/\<\?xml.+\?\>|\<\!DOCTYPE.+]\>/g, "")
.replace(/(<!--.*?-->)|(<!--[\S\s]+?-->)|(<!--[\S\s]*?$)/g, "");
/**
* Try to parse svg:
* invalid svg will return false via "catch"
*/
try {
doc = new DOMParser().parseFromString(markup, "image/svg+xml");
svg = doc.querySelector("svg");
// paths containing only a M command
let nonsensePaths = svg.querySelectorAll('path[d="M0,0"], path[d="M0 0"]');
// create analyzing object
fileReport = {
totalEls: svg.querySelectorAll("*").length,
geometryEls: svg.querySelectorAll(
"path, rect, circle, ellipse, polygon, polyline, line"
).length,
useEls: svg.querySelectorAll("use").length,
useElsNested: 0,
nonsensePaths: nonsensePaths.length,
isSuspicious: false,
isBillionLaugh: false,
hasScripts: svg.querySelectorAll("script").length ? true : false,
hasPrologue: hasPrologue,
hasEntity: hasEntity,
fileSizeKB: +(new Blob([markup]).size / 1024).toFixed(3),
hasXmlns: svg.getAttribute('xmlns') ? (svg.getAttribute('xmlns') === 'http://www.w3.org/2000/svg' ? true : false) : false,
isSymbolSprite: svg.querySelectorAll('symbol').length && svg.querySelectorAll('use').length === 0 ? true : false,
isSvgFont: svg.querySelectorAll('glyph').length ? true : false
};
let totalEls = fileReport.totalEls;
let totalUseEls = fileReport.useEls;
let usePercentage = (100 / totalEls) * totalUseEls;
// if percentage of use elements is higher than 75% - suspicious
if (usePercentage > 75) {
fileReport.isSuspicious = true;
// check nested use references
let nestedCount = countUseRefs(svg.querySelectorAll("use"), maxNested);
if (nestedCount >= maxNested) {
fileReport.isBillionLaugh = true;
}
}
return fileReport;
}
// svg file has malformed markup
catch {
console.log("svg could not be parsed");
return false;
}
}
textarea {
width: 100%;
min-height: 10em;
}
<h3>Malicious svg 1 (billion laugh nested use)</h3>
<textarea id="filecheck1">
<svg version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" xml:space="preserve">
<path id="a" d="M0,0"/>
<g id="b"><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/><use xlink:href="#a"/></g>
<g id="c"><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/><use xlink:href="#b"/></g>
<g id="d"><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/><use xlink:href="#c"/></g>
<g id="e"><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/><use xlink:href="#d"/></g>
<g id="f"><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/><use xlink:href="#e"/></g>
<g id="g"><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/><use xlink:href="#f"/></g>
<g id="h"><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/><use xlink:href="#g"/></g>
<g id="i"><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/><use xlink:href="#h"/></g>
<g id="j"><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/><use xlink:href="#i"/></g>
</svg>
</textarea>
<p class="report"></p>
<p><button onclick="validateSVG(filecheck1.value, {useElsNested:100})">Check validity</button></p>
<h3>Malicious svg 2</h3>
<textarea id="filecheck2">
<!DOCTYPE testingxxe [ <!ENTITY xml "Hello World!"> ]>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<image height="30" width="30" xlink:href="https://yourimage.com" />
<text x="0" y="20" font-size="20">&xml;</text>
</svg>
</textarea>
<p><button onclick="validateSVG(filecheck2.value)">Check validity</button></p>
<h3>Undesired elements svg 3 (contains js)</h3>
<textarea id="filecheck3">
<svg><text>test</text>
<script>alert('Hello World')</script>
</svg>
</textarea>
<p><button onclick="validateSVG(filecheck3.value)">Check validity</button></p>
<h3>Invalid svg 4 (not parseable)</h3>
<textarea id="filecheck4">
< svg > <text x="0" y="20" font-size="20">
</textarea>
<p><button onclick="validateSVG(filecheck4.value)">Check validity</button></p>
<input type="file" class="inputFile hidden" id="inputFile" accept="image/*">
<img id="imgPreview" style="height:2em;">
<script>
inputFile.addEventListener('change', e => {
handleFiles(e.currentTarget, e.currentTarget.files)
})
inputFile.addEventListener('mouseup', e => {
e.currentTarget.value = '';
imgPreview.src = '';
})
function handleFiles(inputEl, files) {
//delete previous
for (let i = 0; i < files.length; i++) {
readFiles(inputEl, files[i]);
}
}
/**
* define allowed/required elements
* or limits
*/
let allowed = {
//useEls: 10,
//hasPrologue: false,
//hasXmlns: true,
useElsNested: 10000,
hasScripts: false,
hasEntity: false,
fileSizeKB: 200,
isSymbolSprite: false,
isSvgFont: false
};
function readFiles(inputEl, file) {
var reader = new FileReader();
let type = file.type;
let isValid = false;
reader.onload = function(e) {
let data = e.target.result;
if (type === 'image/svg+xml') {
// validate
isValid = validateSVG(data, allowed);
if (isValid) {
let dataUrl = URL.createObjectURL(file);
imgPreview.src = dataUrl;
imgPreview.onload = function() {
URL.revokeObjectURL(dataUrl);
};
}
// not valid delete file
else {
inputEl.value = '';
let errorImg = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 27 48'%3E%3Cpath fill='red' d='M26.16,17.92l-10.44,10.44l10.44,10.44l-2.44,2.44l-10.44-10.44l-10.44,10.44l-2.44-2.44l10.44-10.44l-10.44-10.44l2.44-2.44l10.44,10.44l10.44-10.44Z'%3E%3C/path%3E%3C/svg%3E`;
imgPreview.src = errorImg;
}
} else {
imgPreview.src = data;
}
};
//reader.readAsDataURL(file);
if (type === 'image/svg+xml') {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
}
</script>
Based on the retrieved data you can define a custom validation pattern to exclude certain kinds of svg files e.g. by limiting:
the total amount of elements
whether script tags are allowed or not
filesize
etc. and by defining the parameters in "allowed" object (or using the data for custom conditions)
let allowed = { useElsNested: 10000, hasScripts: false, hasEntity: false, fileSizeKB: 200, isSymbolSprite: false, isSvgFont: false };
<use>
referencesOnce the svg file input is parsed
doc = new DOMParser().parseFromString(markup, "image/svg+xml");
svg = doc.querySelector("svg");
we can query <use>
elements
let useEls = svg.querySelectorAll("use")
then we can search for nested use references:
We query href
or xlink:href
attributes to find their referenced elements in another loop.
We can set a maximum limit e.g. 100 nested use referenced elements to speed up the detection process
const countUseRefs = (useEls, maxNested = 200) => {
let nestedCount = 0;
//stop loop if number of nested use references is exceeded
for (let i = 0; i < useEls.length && nestedCount < maxNested; i++) {
let use = useEls[i];
let refId = use.getAttribute("xlink:href")
? use.getAttribute("xlink:href")
: use.getAttribute("href");
refId = refId ? refId.replace("#", "") : "";
//normalize href attributes to facilitate JS selection
use.setAttribute("href", "#" + refId);
let refEl = svg.getElementById(refId);
let nestedUse = refEl.querySelectorAll("use");
let nestedUseLength = nestedUse.length;
nestedCount += nestedUseLength;
// query nested use references
for (let n = 0; n < nestedUse.length && nestedCount < maxNested; n++) {
let nested = nestedUse[n];
let id1 = nested.getAttribute("href").replace("#", "");
let refEl1 = svg.getElementById(id1);
let nestedUse1 = refEl1.querySelectorAll("use");
nestedCount += nestedUse1.length;
}
}
fileReport.useElsNested = nestedCount;
return nestedCount;
};
If the total amount of nested <use>
references exceeds a certain limit – the file is highly suspicious, so we prevent uploading by resetting the input value.
This approach is most certainly not ideal and will result in false-positives.
However, the concept of "pre-parsing" can be helpful to check a svg file for malformed (or all kinds of undesired elements).
Apart from sophisticated server side (sandboxed) testing concepts – the experimental PerformanceElementTiming.renderTime
/PerformanceObserver
sounds promising to actually detect rendering performance – at least for performance "heavy hitters" like svg filters.
Unfortunately not usable due to current browser support..
Besides, the idea of fully rendering/executing a potentially malicious code – probably not a good idea.