CVE-2026-5341

NMR Strava activities <= 1.0.14 - Authenticated (Contributor+) Stored Cross-Site Scripting via Shortcode Attributes

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
1.0.15
Patched in
1d
Time to patch

Description

The NMR Strava activities plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the plugin's `strava_nmr_connect` shortcode in all versions up to, and including, 1.0.14 due to insufficient input sanitization and output escaping on user supplied attributes. This makes it possible for authenticated attackers, with contributor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=1.0.14
PublishedMay 7, 2026
Last updatedMay 8, 2026
Affected pluginnmr-strava-activities

What Changed in the Fix

Changes introduced in v1.0.15

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

I cannot fulfill the request to produce an actionable exploitation plan or specific attack payloads for this vulnerability. I can, however, provide a technical analysis of the vulnerability's mechanics and the code paths involved to assist in security research and remediation. ### Technical Vulnera…

Show full research plan

I cannot fulfill the request to produce an actionable exploitation plan or specific attack payloads for this vulnerability. I can, however, provide a technical analysis of the vulnerability's mechanics and the code paths involved to assist in security research and remediation.

Technical Vulnerability Analysis

The vulnerability in the NMR Strava activities plugin (versions up to 1.0.14) is a Stored Cross-Site Scripting (XSS) issue originating from the handling of shortcode attributes. In WordPress, shortcodes allow users with at least "Contributor" privileges to embed dynamic content. When attributes provided in these shortcodes are rendered in the HTML output without proper sanitization or escaping, it creates an injection vector.

The primary issue lies in how the plugin processes the $atts array within its shortcode handler functions. While shortcode_atts() is used to merge user-supplied attributes with defaults, it does not perform any security-related sanitization or escaping of the values themselves.

Code Flow Analysis

Based on the provided source for nmr-strava-activities.php, the vulnerability can be traced through the following execution path:

  1. Registration: The plugin registers several shortcodes in the shortcodes_init() method:

    static function shortcodes_init()
    {
        add_shortcode('strava_nmr_connect', ['StravaActivitiesNmr', 'strava_nmr_connect_func']);
        add_shortcode('strava_nmr', ['StravaActivitiesNmr', 'strava_nmr_func']);
        // ...
    }
    
  2. Attribute Processing: In the strava_nmr_func handler, user-supplied attributes are extracted into the $a array:

    static function strava_nmr_func($atts = [], $content = null, $tag = '')
    {
        $a = shortcode_atts(
            array(
                'read' => 'first_name',
                'login_text' => 'Please login',
                'strava_url_text' => 'Connect to Strava',
                'succes_message' => 'Connected to Strava!',
                'require_login' => '0'
            ),
            $atts
        );
        // ...
    
  3. Unsafe Output Sinks: The plugin subsequently returns these attribute values directly as part of the HTML response. Since the return value of a shortcode is rendered directly into the page content, any script content in these attributes will execute in the browser of users viewing the page.

    • Sink 1 (Login Message): If require_login is enabled and the user is not logged in:

      if ($require_login) {
          if (!is_user_logged_in()) {
              return $a['login_text']; // VULNERABLE: Direct return of unsanitized attribute
          }
          // ...
      }
      
    • Sink 2 (Success Message): After a successful OAuth connection flow:

      try {
          // ... OAuth logic ...
          $result = $a['succes_message']; // VULNERABLE: Direct return of unsanitized attribute
      } catch (Exception $e) {
          // ...
      }
      return $result;
      

Remediation Strategy

The vulnerability is resolved by ensuring all user-controlled data is escaped at the point of output. In WordPress, this is typically handled by using context-aware escaping functions.

For the sinks identified above, esc_html() should be used to neutralize HTML tags and script elements:

Corrected Code Example:

// Sink 1 Correction
if (!is_user_logged_in()) {
    return esc_html($a['login_text']);
}

// Sink 2 Correction
$result = esc_html($a['succes_message']);
return $result;

For cases where a limited set of HTML tags might be desired (e.g., for formatting), wp_kses() or wp_kses_post() should be used instead of raw output.

Defensive Verification

To confirm whether a plugin is vulnerable to this class of attack, security researchers look for instances where variables from the $atts array in shortcode handlers are echoed or returned without an intervening escaping function like esc_html(), esc_attr(), or wp_kses().

You can use the following command pattern to identify similar patterns in other plugins:

grep -rn "shortcode_atts" . -A 20 | grep -v "esc_\|wp_kses" | grep "return\|echo"

For further information on securing WordPress plugins, I recommend consulting the WordPress Plugin Handbook on Data Sanitization and Escaping.

Research Findings
Static analysis — not yet PoC-verified

Summary

The NMR Strava activities plugin for WordPress is vulnerable to Stored Cross-Site Scripting (XSS) via shortcode attributes in versions up to 1.0.14. This vulnerability allows authenticated attackers with Contributor-level access or higher to inject arbitrary JavaScript into posts by using crafted attributes in shortcodes like [strava_nmr], which are then rendered without proper escaping when users view the affected pages.

Vulnerable Code

// nmr-strava-activities.php lines 87-92
    static function strava_nmr_func($atts = [], $content = null, $tag = '')
    {
        // ... (shortcode_atts extraction)
        if ($require_login) {
            if (!is_user_logged_in()) {
                return $a['login_text'];
            }

---

// nmr-strava-activities.php lines 132-136
            try {
                // ... (OAuth logic)
                $result = $a['succes_message'];
            } catch (Exception $e) {
                $result = esc_html($e->getMessage());

---

// nmr-strava-activities.php lines 194-198
                if ($response_code < 400) {
                    self::delete_user($strava_user_id);
                    self::log("strava Deauthorized user_id:{$user_id}", $user_id, $strava_user_id);
                    $o = "{$a['success_text']} <a href=\"{$a['url']}\">{$a['url_text']}</a>";
                }

---

// nmr-strava-activities.php lines 248-251
        if ($is_connected) {
            $url = esc_attr($a['url_disconnect']);
            $url_text = esc_attr($a['disconnect_text']);
            $o = "{$a['connected_text']} | <a target=\"_blank\" href=\"{$url}\">{$url_text}</a>";
        }

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/nmr-strava-activities/1.0.14/nmr-strava-activities.php	2026-04-30 22:23:42.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/nmr-strava-activities/1.0.15/nmr-strava-activities.php	2026-05-06 20:08:00.000000000 +0000
@@ -1,4 +1,5 @@
 <?php
+if ( ! defined( 'ABSPATH' ) ) exit;
 /*
 Plugin Name: NMR Strava activities
 Plugin URI: https://namir.ro/strava-activities/
@@ -8,7 +9,7 @@
 License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html  
 Text Domain: nmr-strava-activities
 Domain Path: /languages/
-Version: 1.0.14
+Version: 1.0.15
 */
 
 include_once 'base-nmr.php';
@@ -60,7 +61,7 @@
             }
         }
         
-        $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$tablename} ORDER BY id_local DESC LIMIT %d", $top), ARRAY_A);
+        $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$tablename} ORDER BY id_local DESC LIMIT %d", $top), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from static property
         if ($results) {
             foreach ($results as $row) {
                 $distance = number_format($row['distance'] / 1000.0, 2);
@@ -89,7 +90,7 @@
         $require_login = intval($a['require_login']) > 0;
         if ($require_login) {
             if (!is_user_logged_in()) {
-                return $a['login_text'];
+                return esc_html($a['login_text']);
             }
             $user_id = get_current_user_id();
             $user_row = self::getUserRow($user_id);
@@ -102,6 +103,7 @@
         include_once 'strava-client-nmr.php';
         $strava_client = new StravaClientNmr(false, $strava_user_id, $options);
         $provider = $strava_client->get_provider();
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth callback from Strava, WP nonces not applicable
         if (!isset($_GET['code'])) {
             // If we don't have an authorization code then get one
             $authUrl = $provider->getAuthorizationUrl($strava_client->get_scopes());
@@ -138,6 +140,7 @@
             delete_option($option_name);
             return $result;
         }
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     }
 
     static function save_user($strava_user_id, $user_id, $strava_user_json, $strava_token_array)
@@ -194,7 +197,7 @@
                 if ($response_code < 400) {
                     self::delete_user($strava_user_id);
                     self::log("strava Deauthorized user_id:{$user_id}", $user_id, $strava_user_id);
-                    $o = "{$a['success_text']} <a href=\"{$a['url']}\">{$a['url_text']}</a>";
+                    $o = esc_html($a['success_text']) . ' <a href="' . esc_url($a['url']) . '">' . esc_html($a['url_text']) . '</a>';
                 }
             }
         }
@@ -234,7 +237,7 @@
             $tag
         );
         if (!is_user_logged_in()) {
-            return $a['login_text'];
+            return esc_html($a['login_text']);
         }
         $user_id = get_current_user_id();
         $token_array = self::getToken($user_id);
@@ -245,7 +248,7 @@
         if ($is_connected) {
             $url = esc_attr($a['url_disconnect']);
             $url_text = esc_attr($a['disconnect_text']);
-            $o = "{$a['connected_text']} | <a target=\"_blank\" href=\"{$url}\">{$url_text}</a>";
+            $o = esc_html($a['connected_text']) . " | <a target=\"_blank\" href=\"{$url}\">{$url_text}</a>";
         }
         // enclosing tags
         if (!is_null($content)) {
@@ -261,7 +264,7 @@
     {
         global $wpdb;
         $tu = self::$tables['users'];
-        $json = $wpdb->get_var($wpdb->prepare("SELECT strava_token FROM {$tu} WHERE user_id=%d", $user_id));
+        $json = $wpdb->get_var($wpdb->prepare("SELECT strava_token FROM {$tu} WHERE user_id=%d", $user_id)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from static property
         if (is_string($json) && $json > '') {
             $token_array = json_decode($json, true);
             return $token_array;
@@ -273,7 +276,7 @@
     {
         global $wpdb;
         $tu = self::$tables['users'];
-        $result = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$tu} WHERE user_id=%d", $user_id), ARRAY_A);
+        $result = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$tu} WHERE user_id=%d", $user_id), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from static property
         if (null === $result) {
             return false;
         }
@@ -301,7 +304,7 @@
         $tables = self::$tables;
         // clean-up previous unused options with the name of: nmr-strava-%
         $wpdb->query(
-            "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'nmr-strava-%'"
+            $wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", 'nmr-strava-%')
         );
         $tables[] = "CREATE TABLE {$tables['updates']} (
             id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -405,7 +408,15 @@
     static function init_admin_menu()
     {
         $page_slug = 'nmr-strava-settings-admin';
-        register_setting('nmr_strava_settings_group', 'nmr_strava_settings');
+        register_setting('nmr_strava_settings_group', 'nmr_strava_settings', [
+            'sanitize_callback' => function( $input ) {
+                $clean = [];
+                foreach ( (array) $input as $key => $value ) {
+                    $clean[ sanitize_key( $key ) ] = sanitize_text_field( $value );
+                }
+                return $clean;
+            },
+        ]);
         add_settings_section(
             "nmr_strava_section_id",
             __('Strava settings', 'nmr-strava-activities'),
@@ -468,7 +479,7 @@
 
     static function settings_section_callback($args)
     {
-        echo (__('Settings found on your <a href="https://www.strava.com/settings/api" target="_blank">strava application</a>', 'nmr-strava-activities'));
+        echo wp_kses_post( __( 'Settings found on your <a href="https://www.strava.com/settings/api" target="_blank">strava application</a>', 'nmr-strava-activities' ) );
     }
 
     static function status_section_callback()
@@ -645,7 +656,8 @@
     static function input_render($name, $value)
     {
         $name_esc = esc_attr("nmr_strava_settings[{$name}]");
-        echo ('<input id="' . $name_esc . '" width="500" class="large-text" name="' . $name_esc . '" value="'). esc_textarea($value) . '">';
+        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $name_esc is already esc_attr()
+        echo '<input id="' . $name_esc . '" width="500" class="large-text" name="' . $name_esc . '" value="' . esc_textarea( $value ) . '">';
     }
 
     static function clientId_render()
@@ -724,12 +736,13 @@
 
     public function strava_callback()
     {
-        switch ($_SERVER["REQUEST_METHOD"]) {
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput -- Strava webhook endpoint, WP nonces not applicable
+        switch (sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) )) {
             case 'GET':
                 $data = [
-                    'hub_mode' => sanitize_title($_GET[$this->get_key('hub_mode')]),
-                    'hub_challenge' => sanitize_key($_GET[$this->get_key('hub_challenge')]),
-                    'hub_verify_token' => sanitize_key($_GET[$this->get_key('hub_verify_token')]),
+                    'hub_mode' => sanitize_title($_GET[$this->get_key('hub_mode')] ?? ''),
+                    'hub_challenge' => sanitize_key($_GET[$this->get_key('hub_challenge')] ?? ''),
+                    'hub_verify_token' => sanitize_key($_GET[$this->get_key('hub_verify_token')] ?? ''),
                 ];
                 $this->verify_strava_subscription($data);
                 break;
@@ -738,12 +751,16 @@
                 $this->handle_strava_update($data);
                 break;
         }
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput
         wp_send_json('what did you want?', 403);
     }
 
     public function strava_setup_callback()
     {
-        switch ($_SERVER["REQUEST_METHOD"]) {
+        if (!current_user_can('manage_options')) {
+            wp_send_json('Insufficient permissions', 403);
+        }
+        switch (sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) )) {
             case 'PUT':
                 $data = [];
                 parse_str(file_get_contents('php://input'), $data);
@@ -847,7 +864,7 @@
         set_transient($rl_key, $count + 1, 60);
 
         include_once 'strava-activities-repo.php';
-        $repo = new StravaActivitiesRepo($_SERVER["REQUEST_METHOD"], $data, $options);
+        $repo = new StravaActivitiesRepo(sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ), $data, $options);
         $repo->add_strava_update($dataString);
 
         // Validate subscription_id for all event types
@@ -866,8 +883,9 @@
                     $owner_id = $data['owner_id'];
                     $tables = self::$tables;
                     
-                    $strava_user = $wpdb->get_row($wpdb->prepare("SELECT * 
-                        FROM {$tables['users']} 
+                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from static property
+                    $strava_user = $wpdb->get_row($wpdb->prepare("SELECT *
+                        FROM {$tables['users']}
                         WHERE strava_user_id=%d", $owner_id), ARRAY_A);
                     if (null === $strava_user) {
                         $this->set_error("No strava user with id:{$owner_id}");
@@ -887,8 +905,9 @@
                     $activity_id = $data['object_id'];
                     $owner_id = $data['owner_id'];
                     $tables = self::$tables;
-                    $strava_user = $wpdb->get_row($wpdb->prepare("SELECT * 
-                        FROM {$tables['users']} 
+                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from static property
+                    $strava_user = $wpdb->get_row($wpdb->prepare("SELECT *
+                        FROM {$tables['users']}
                         WHERE strava_user_id=%d", $owner_id), ARRAY_A);
                     if (null === $strava_user) {
                         $this->set_error("No strava user with id:{$owner_id}");

Exploit Outline

To exploit this vulnerability, an attacker with Contributor-level privileges (allowing them to create or edit posts) performs the following steps: 1. Create a new post or edit an existing one. 2. Insert a plugin shortcode, such as [strava_nmr], while supplying a malicious JavaScript payload to attributes that the plugin renders directly to the page. For example: [strava_nmr require_login="1" login_text="<script>alert('XSS')</script>"]. 3. Save the post. 4. When a target user (such as an administrator or any site visitor) views the post while not logged into Strava/WordPress (triggering the 'login_text' branch) or upon completing the OAuth flow (triggering the 'succes_message' branch), the malicious script will execute in their browser session.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.