CVE-2026-105078 The shortcode attribute that was trusted like a setting in WP Store Locator

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in WP Store Locator, affecting all versions up to and including 3.0.3. The [wpsl] shortcode's template attribute is concatenated into the wrapper element's class value with no escaping, so a Contributor can close the attribute and add an event handler. Fixed in 3.1.0.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
8 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored cross-site scripting, CWE-79
Affected
WP Store Locator ≤ 3.0.3
Fixed in
3.1.0
Active installs
40,000+
Attacker privilege
Contributor, the lowest role that can write a post
Victim
Every visitor to the published post, logged out included
Execution
One shortcode attribute, no interaction

Summary

WP Store Locator puts a searchable store map on about 40,000 WordPress sites, placed with the [wpsl] shortcode. The shortcode takes a template attribute choosing which of the bundled layouts to use.

That value is put into a CSS class name, wpsl-<template>-template, and the class list is joined and printed with the quotes written by hand and no esc_attr() anywhere along the way. The three bundled templates all emit the wrapper the same way.

So a template value containing a double quote ends the class attribute, and everything after it becomes further attributes on the wrapper div. With autofocus and a tabindex among them, the element takes focus while the page is parsed and an onfocus handler runs with nothing clicked.

A shortcode is plain text in the database. The payload carries no angle bracket, so wp_filter_post_kses, which strips tags, has nothing to remove and a Contributor's post keeps it byte for byte.

Technical detail

From shortcode attribute to class value

// includes/frontend/assets/class-manager.php, WP Store Locator 3.0.3

public function get_outer_class( $shortcode_atts = [] ) {      // line 695
    $classes = $this->get_outer_classes( $shortcode_atts );

    if ( ! empty( $classes ) ) {
        return 'class="' . join( ' ', $classes ) . '"';  // line 699
    }
}


public function get_outer_classes( $shortcode_atts = [] ) {    // line 704
    ...
    // Check if we need to use the shortcode template ID, or the one
    // set on the WPSL settings page.
    if ( isset( $shortcode_atts['template'] ) && $shortcode_atts['template'] ) {  // 719
        $wpsl_template = $shortcode_atts['template'];                // line 720
    } else {
        $wpsl_template = $this->settings->get( 'appearance', 'template_id' );
    }

    $classes[] = 'wpsl-' . $wpsl_template . '-template';          // line 725
    ...
}

// includes/frontend/shortcodes/class-shortcodes.php:143
$this->atts = array_merge( $this->atts, $atts );   // no allowlist, no sanitisation

Line 720 takes the attribute as it arrives. Line 725 concatenates it into a class name. Line 699 joins the list and wraps it in quotes it writes itself. There is no escaper on that path, and class-shortcodes.php:143 merges the shortcode attributes with no allowlist, so nothing upstream constrains the value either.

Who writes the angle brackets

// the three bundled front-end templates all print it the same way

// includes/frontend/templates/default.php:13
// includes/frontend/templates/horizontal.php:13
// includes/frontend/templates/vertical.php:31

$output .= '<div id="wpsl-wrap" ' . $assets_manager->get_outer_class() . '>';

// the template writes the angle brackets. get_outer_class() writes
// the quotes. the attacker only has to supply one quote of their own.

This is the detail that makes the payload short. The attacker needs neither < nor >, because the template supplies both, and needs only one quote character to end the attribute the plugin opened.

Verification

WordPress 7.1.3, PHP 8.3, WP Store Locator 3.0.3 from wordpress.org, default settings, on http://127.0.0.1:8515. Two accounts: admin, and contrib created as an ordinary Contributor. No plugin setting was changed.

# stored over HTTP as the Contributor, through the REST API

principal: contrib ['contributor'] | unfiltered_html=False

stored: [wpsl template='x" tabindex="1" autofocus
         onfocus="alert(document.domain)"//']

kept by wp_kses byte for byte: True

# the payload carries no angle bracket, so wp_filter_post_kses, which
# strips tags, finds nothing. a shortcode is plain text until it runs.

What the published post serves

// served to an anonymous visitor once the post is published

<div id="wpsl-wrap" class="wpsl-x" tabindex="1" autofocus
     onfocus="alert(document.domain)//-template wpsl-static-hours">


// as the browser parsed it:
id="wpsl-wrap"
class="wpsl-x wpsl-labels-aligned wpsl-no-results"
tabindex="1" | autofocus=""
onfocus="alert(document.domain)//-template wpsl-static-hours"

cookies: 0   dialogs: 1   message: 127.0.0.1

// the rest of the class list ended up inside the handler, where the
// trailing // comments it out. autofocus with tabindex takes focus
// during load, so nothing is clicked.

The control

# control: the same shortcode with a real template name
# [wpsl template='default']

[3.0.3 control] cookies=0  dialogs: 0
  parsed wrapper:
    id="wpsl-wrap"
    class="wpsl-default-template wpsl-static-hours wpsl-labels-aligned
            wpsl-no-results"

  # one clean class attribute, nothing injected. the shortcode and
  # the template selection both work. the quote is the payload.


# instrument control, before every run
# data:text/html,<script>alert('instrument-control')</script> -> 1 dialog

The two runs differ in one thing: the value of the template attribute. That isolates the missing escaper. The shortcode being present changes nothing.

3.1.0

// WP Store Locator 3.1.0, same lab, the same two published posts

[3.1.0 payload] cookies=0  dialogs: 0
  class="wpsl-default-template wpsl-static-hours wpsl-labels-aligned
          wpsl-no-results"        // the id was rejected, the default used

[3.1.0 control] cookies=0  dialogs: 0
  class="wpsl-default-template wpsl-static-hours wpsl-labels-aligned
          wpsl-no-results"        // unchanged, the feature still works

Both posts now render the same clean class list. The payload's template id is refused and the configured default is used in its place, so the attacker's string never reaches the output. The control renders exactly as it did on 3.0.3, so the shortcode and the template selection both still work.

Impact

Contributor is the lowest WordPress role that can author content, handed to guest writers, volunteers and agency staff on the assumption that wp_kses bounds what they can put on a page.

The review step gives no signal. What an editor sees in the submission is a shortcode with an odd-looking template name, and shortcodes routinely carry values a reviewer would not recognise.

Once published, the script runs for every visitor who opens the post, logged out included, in the site's own origin, with no interaction at all. That was measured with zero cookies in the browser. An administrator who opens the post on the front end runs it in their session, where the session cookie and the REST nonce printed on the page are enough to create a second administrator account or install a plugin, which is PHP of the attacker's choosing on the server.

No configuration is involved. The shortcode is the plugin's primary interface and all three bundled templates carry the same sink.

Remediation

Update WP Store Locator to 3.1.0 or later.

// WP Store Locator 3.1.0, both of the things the report asked for

// the escaper, line 742
- return 'class="' . join( ' ', $classes ) . '"';
+ return 'class="' . esc_attr( join( ' ', $classes ) ) . '"';


// and the allowlist, line 767, with its own note:
//   a caller ( a v2 custom template ) can pass its own attributes,
//   so only a registered template id is used here too.

- if ( isset( $shortcode_atts['template'] ) && $shortcode_atts['template'] ) {
+ if ( isset( $shortcode_atts['template'] ) && $shortcode_atts['template']
+     && in_array( $shortcode_atts['template'], $registered_ids ) ) {
      $wpsl_template = $shortcode_atts['template'];
  }

Two changes, which together are both of the things the report asked for. The escaper means a class list containing anything unexpected stays inside the attribute. The allowlist means an unregistered template id is refused outright, which is what actually stops the measured payload: it never reaches the escaper, because the configured default is substituted first.

A site attacked before updating needs no database cleanup. The stored shortcode is inert text, and on 3.1.0 it renders the default template. The post still contains something its author did not write, so affected posts are worth reviewing.

The wider lesson

A shortcode attribute reads like configuration. It is written in the post by somebody choosing a layout, it names a template, and the set of sensible values is small and known. That framing is what makes the escaper easy to leave out: the value feels like it came from the developer, because in every test it did.

It comes from the post content, which is written by whoever can edit the post. Every shortcode attribute on a WordPress site is attacker-controlled at Contributor level, and the ones naming a template, a layout, a style or a size are the ones most likely to be interpolated into markup without a second thought.

The second point is about who writes the delimiters. esc_attr() protects a value inside an attribute, and the question is always which characters end the thing the value sits in. Here the plugin writes class=", the template writes <div ... >, and the attacker supplies the one quote that joins them into something else. When output is assembled from several places, the escaping has to be decided by the place that writes the quotes, which was line 699 and did not do it.

Worth noting too: the same codebase already knew. class-shortcodes.php checks the template against a known list in another method, for a different purpose. The constraint existed and had not been applied on the path that needed it, which is the shape of most of these findings.

Disclosure timeline

  • 2026-09-25 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-07 Release 3.1.0 published by Tijmen Smit
  • 2026-10-08 Public disclosure by Patchstack, CVE-2026-105078 published

Questions

What is CVE-2026-105078?

CVE-2026-105078 is a stored cross-site scripting vulnerability in WP Store Locator, affecting all versions up to and including 3.0.3. The template attribute of the [wpsl] shortcode is concatenated into a CSS class name and the class list is printed without esc_attr(), so a value containing a double quote ends the attribute and what follows becomes further attributes on the wrapper element.

Who can exploit CVE-2026-105078?

Any user who can write a post, which means Contributor and above. A shortcode is plain text in post content and the payload contains no angle brackets, so wp_filter_post_kses, which strips tags, leaves it byte for byte.

Does the visitor have to click anything?

No. The payload adds autofocus and a tabindex alongside the event handler, so the wrapper element takes focus while the page is parsed and the handler runs during load. This was confirmed on an anonymous page load with no cookies.

Do I need to clean my database after updating?

No. The stored shortcode is inert text, and on 3.1.0 an unregistered template id is refused and the configured default is used instead, so the page renders normally. The post still contains a shortcode its author did not write, so it is worth reviewing.

How do I fix CVE-2026-105078?

Update WP Store Locator to 3.1.0 or later. That release applies esc_attr() to the joined class list and accepts the shortcode's template attribute only when it names a registered template, so an arbitrary value never reaches the markup.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.