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>
CodePudding user response:
Heuristic validation based on raw text and parsed markup
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:
- raw text level: (regexpattern).test(fileinputString) if there are any suspicious/undesired elements like xml
[!entity]
- parsed markup level (not rendered!): the file input data is parsed via
new DOMParser().parseFromString(markup, "image/svg xml")
to query potentially malicious or undesired elements (e.g.<script>
tags)
Example: analyze svg for validation
The used analyzeSVG(markup, allowed)
helper function checks element occurrences like:
- script tags
- nested
<use>
elements - total number of elements
- total number of
<use>
instances
etc.
Scroll 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,
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");
// 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,
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><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" 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,`;
imgPreview.src = errorImg;
}
} else {
imgPreview.src = data;
}
};
//reader.readAsDataURL(file);
if (type === 'image/svg xml') {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
}
</script>
How it works
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 };
Detect "billion laugh attack" based on multiplied/nested <use>
references
Once 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).
Actually testing rendering performance in JavaScript?
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.
CodePudding user response:
You can set a max file size for file uploads. That should solve you problem