CVE-2023-2711

the server sanitized it, the browser did not: stored XSS in Ultimate Product Catalogue

Ultimate Product Catalogue (<= 5.2.5) has a getting-started wizard for adding categories. The server-side handler sanitizes the category name correctly. The JavaScript then ignores the sanitized server response and rebuilds the table row from the raw value you typed, with jQuery.append. So the sanitizer runs, and the XSS fires anyway.

a false lead first

I almost wrote this plugin off. I started in the obvious place, the custom fields admin page, and found esc_attr() doing its job and the interesting code locked behind the premium tier. The escaping was there. If I had stopped at the PHP I would have called it clean.

So I diffed 5.2.5 against the patched 5.2.6 to see what they actually changed. The fix was not in any PHP file. It was one line added to a JavaScript file. That told me the bug was client side, and pointed me at the getting-started wizard.


the sink is in the browser

assets/js/ewd-upcp-welcome-screen.js, the add-category handler:

jQuery('.ewd-upcp-welcome-screen-add-category-button').on('click', function() {

    var category_name = jQuery('.ewd-upcp-welcome-screen-add-category-name input').val();
    ...
    var data = jQuery.param( params );
    jQuery.post(ajaxurl, data, function(response) {
        var HTML = '<tr class="upcp-welcome-screen-category">';
        HTML += '<td class="upcp-welcome-screen-category-name">' + category_name + '</td>';
        HTML += '<td class="upcp-welcome-screen-category-description">' + category_description + '</td>';
        HTML += '</tr>';

        jQuery('.ewd-upcp-welcome-screen-show-created-categories').append(HTML);
        ...
    });
});

Trace category_name. It is read straight from the input with .val(). It is posted to the server. Then, in the success callback, the new table row is built by string-concatenating category_name into HTML and handed to jQuery.append().

jQuery.append() parses a string as HTML and inserts the resulting nodes. An <img onerror> in that string fires. This is the same mechanism behind a long line of jQuery DOM-XSS bugs.


why the server-side sanitize is a decoy

The AJAX action ewd_upcp_welcome_add_category does sanitize the name before saving it. That is real, and it is why the custom-fields page looked safe. But it does not matter here, because the JavaScript never uses what the server sends back for the name. Look again: the response is parsed later for the category id, but the visible row is built from category_name, the raw variable the browser has held since .val().

So the data flow forks. The server gets a clean copy and stores it clean. The browser keeps the dirty copy and renders it. The XSS executes in the page regardless of what ends up in the database. The sanitizer is guarding the wrong copy.

This is the lesson I took from the diff: an output-side fix in PHP can be perfect and still leave a client-side sink wide open, because the client made its own decision about which value to trust.


the payload

Because this is client side and the value goes straight from .val() into .append(), there is no PHP slashing to fight. A plain image error handler is enough:

<img src=x onerror=alert(document.domain)>

I put that in the Category Name field and clicked Add Category.


reproduction

Local WordPress 6.2, plugin pinned to 5.2.5, admin session.

Dashboard → Product Catalog getting-started → 1. Categories → Category Name

The wizard lives at index.php?page=ewd-upcp-getting-started. Category name:

<img src=x onerror=alert(document.domain)>

Click Add Category. The callback builds the Created Categories row from the raw value, jQuery.append parses it, and the broken image fires its handler.

[DIALOG] type=alert message="localhost"

CVE-2023-2711 stored XSS firing in the Ultimate Product Catalogue getting-started wizard

You can see the broken image icon sitting in the Created Categories table. That is the <img src=x> that ran the onerror. The red bar is the injected banner.


impact

CVSS 4.4, authenticated. The name also persists as a real category, so depending on how the catalogue renders names elsewhere this reaches beyond the wizard. The immediate, reliable fire is in the getting-started screen, which any admin setting the plugin up will load.


the fix

5.2.6 strips tags from the value in the JavaScript before it is concatenated into the row:

category_name = category_name.replace(/(<([^>]+)>)/ig, "");

A blunt instrument, but it closes the client-side sink. The cleaner habit is to never build DOM from string concatenation: set .text() on a created cell instead of .append()ing an HTML string, so the value can never be parsed as markup.


references