Search code examples
javascriptsvgmemorybrowser

How to identify if SVG is consuming all browser's memory and snaps the window


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>


Solution

  • 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:

    1. raw text level: (regexpattern).test(fileinputString) if there are any suspicious/undesired elements like xml [!entity]
    2. 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,
          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>

    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.