The Exploit
An authenticated WordPress user with contributor-level permissions (or higher) can extract the full template data—including layouts, styles, and HTML—from any private or draft Elementor template by sending a single AJAX request.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_[hash]=contributor_user_session
action=elementor_ajax&actions=get_template_data&template_id=123
The response body contains the complete template JSON structure, revealing design patterns, component hierarchies, and content from templates marked as private or draft—data the user has no business seeing. An attacker with contributor access can iterate over template IDs to enumerate and exfiltrate all non-public templates in the site library.
What the Patch Did
Before
$can_read_template = $is_private_or_non_published || $this->wordpress_adapter->current_user_can( 'edit_post', $post_id );
After
$can_read_template = ! $is_private_or_non_published || $this->wordpress_adapter->current_user_can( 'edit_post', $post_id );
The patch adds a negation operator (!) to the first term of the authorization check. The vulnerable code granted read access to templates that were private or non-published, which inverts the intended logic entirely. The fixed version denies access to private/non-published templates unless the user holds the edit_post capability for that specific post. This is a capability check enforced via the WordPress current_user_can() API, which respects post ownership and role-based permissions.
Root Cause
CWE-863: Incorrect Authorization. The vulnerability is a logic error in the permission gate is_allowed_to_read_template() in includes/template-library/sources/local.php at line 2092. The template_id parameter arrives via the AJAX get_template_data action, flows directly into the check without sanitization (the template ID is a post ID, which is an integer), and is evaluated against the inverted condition. The attacker-controlled parameter template_id determines which post's status flag is checked; the logic inversion allows any request where the post is private or draft to pass the gate, crossing the trust boundary between contributor-level users (who may not own the template) and the sensitive template data store. The dataflow is: $_POST['template_id'] → get_template_data() → is_allowed_to_read_template( $post_id ) → inverted boolean → return full template object.
Why It Works
The single load-bearing line is the negation operator: ! $is_private_or_non_published. Remove it, and the vulnerability is fully exploitable. The engineer added the operator because the original logic read: "grant access if the template is private OR the user can edit it." That is backwards. The correct logic must be: "grant access if the template is NOT private AND non-published, OR the user can edit it." By prepending !, the first branch now correctly says "allow read access to public, published templates" and the second branch says "OR grant access if you own it." This is defense in depth: the first branch is the ambient permission (published templates are readable by anyone with contributor access), and the second is the override (you can always read your own template, even if draft). Without the negation, the override was the only gate that mattered, and it was negated too—making private templates readable to anyone.
Hardening Checklist
-
Audit all boolean permission checks for inverted logic. Use static analysis tools like
phpstanwith a ruleset for suspicious boolean patterns, or grep for||and&&in permission contexts and manually verify operator precedence and negation. -
Enforce capability checks at the data-retrieval layer, not the UI layer. Every function that returns sensitive data should call
current_user_can()before querying and returning the data, not just before rendering. Use WordPress's hook system (pre_get_posts,posts_where) to filter queries at the database level. -
Test authorization bypasses in your test suite. Write parameterized tests that verify a low-privilege user cannot read/edit/delete posts they don't own, and run these tests against all AJAX endpoints that accept a post ID. Include tests for draft, private, and password-protected post statuses.
-
Use explicit ACL checks, not negative logic. Replace conditions like
if ( $is_private ) allowwithif ( $user_id === $post_author_id || current_user_can( 'manage_options' ) ) allow. Positive assertions are harder to invert by mistake. -
Validate template_id is a valid, owned post before processing. Call
get_post()with the supplied ID and verify the returned post object exists and the current user has theread_postcapability (which WordPress provides for access control). Fail closed if either check fails.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-1206