The Exploit
Attacker needs authenticated Contributor-level access or above.
curl -i -X POST "https://TARGET_HOST/wp-admin/admin-ajax.php?action=elementor_ajax_save" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Cookie: wordpress_logged_in_abcd1234=..." \
-d "post_id=123&data[settings][ha_page_custom_js]=alert('XSS');"
Then trigger it by visiting the infected page:
GET /?p=123 HTTP/1.1
Host: TARGET_HOST
Cookie: wordpress_logged_in_abcd1234=...
User-Agent: Mozilla/5.0
The save request returns a normal Elementor success response, and the page load later contains the injected <script>alert('XSS');</script> in the rendered page output. This proves that a non-admin editor can store arbitrary custom JavaScript in page settings and execute it whenever that page is viewed.
What the Patch Did
Before:
public function before_save_data( $data ) {
if ( ! current_user_can( 'administrator' ) ) {
$page_setting = get_post_meta( get_the_ID(), '_elementor_page_settings', true );
if ( isset( $data['settings']['ha_page_custom_js'] ) && isset( $page_setting['ha_page_custom_js'] ) ) {
$prev_js = isset( $page_setting['ha_page_custom_js'] ) ? trim( $page_setting['ha_page_custom_js'] ) : '';
$data['settings']['ha_page_custom_js'] = $prev_js;
}
}
return $data;
}
After:
public function before_save_data( $data ) {
if ( ! current_user_can( 'administrator' ) && isset( $data['settings']['ha_page_custom_js'] ) ) {
$page_setting = get_post_meta( get_the_ID(), '_elementor_page_settings', true );
if ( isset( $page_setting['ha_page_custom_js'] ) ) {
// Restore previous value if it exists.
$data['settings']['ha_page_custom_js'] = trim( $page_setting['ha_page_custom_js'] );
} else {
// Remove any custom JS attempt from non-admin users
unset( $data['settings']['ha_page_custom_js'] );
}
}
return $data;
}
The patch added an explicit authorization enforcement step: for non-admins, any submitted ha_page_custom_js value is removed when no previous custom JS exists. It uses current_user_can('administrator') and unset() to prevent the unauthorized payload from reaching the save path.
Root Cause
This is a Stored Cross-Site Scripting bug caused by improper authorization logic in extensions/custom-js.php. The attacker-controlled value enters via the request field data[settings][ha_page_custom_js], is passed into the before_save_data() hook, and is returned unchanged into the page save data when page_setting['ha_page_custom_js'] does not already exist. That means a Contributor can inject JavaScript into _elementor_page_settings metadata, which is later rendered as custom JS on page load. The flaw crosses the trust boundary between authenticated editor input and page output without enforcing the intended administrator-only restriction on custom JavaScript.
Why It Works
The load-bearing fix is the new unset( $data['settings']['ha_page_custom_js'] ); line. Before the patch, the code only restored a previous JS value when one existed; if the page had no prior ha_page_custom_js, the condition failed and the malicious payload was left in place. The new unset() closes that gap by removing the attacker-controlled parameter entirely for non-admins. The surrounding changes (isset( $data['settings']['ha_page_custom_js'] ) and the recovery branch) are defensive: they avoid processing when no custom JS was supplied and preserve existing admin-entered JS instead of deleting it.
Hardening Checklist
- Use
current_user_can('administrator')or equivalent capability checks before accepting privileged fields like custom JS. - Remove unauthorized payloads with
unset()instead of relying on conditional restoration logic. - Protect AJAX save endpoints with
check_ajax_referer()so authenticated editors cannot abuse stale or forged requests. - Sanitize or escape custom script fields at the last possible point, for example
sanitize_textarea_field()on save oresc_js()on output. - Store privileged configuration only in metadata accessible to the approved role and verify it again before writing with
get_post_meta().
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-14635