CVE-2026-2687

Reading progressbar < 1.3.1 - Authenticated (Administrator+) Stored Cross-Site Scripting

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

Description

The Reading progressbar plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to 1.3.1 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with administrator-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. This only affects multi-site installations and installations where unfiltered_html has been disabled.

CVSS Vector Breakdown

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

Technical Details

Affected versions<1.3.1
PublishedMarch 12, 2026
Last updatedMarch 19, 2026
Affected pluginreading-progress-bar

What Changed in the Fix

Changes introduced in v1.3.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan focuses on exploiting a Stored Cross-Site Scripting (XSS) vulnerability in the **Reading progressbar** plugin for WordPress. ### 1. Vulnerability Summary The "Reading progressbar" plugin fails to sanitize user-controlled settings before storing them and fails to escape them when …

Show full research plan

This research plan focuses on exploiting a Stored Cross-Site Scripting (XSS) vulnerability in the Reading progressbar plugin for WordPress.

1. Vulnerability Summary

The "Reading progressbar" plugin fails to sanitize user-controlled settings before storing them and fails to escape them when outputting them to the page. Specifically, values stored in the rp_settings option are echoed directly into the HTML of both the admin settings page and the public-facing frontend.

This allows an authenticated user with Administrator privileges (even if unfiltered_html is disabled, such as on a Multisite installation) to inject arbitrary JavaScript. The payload is stored in the WordPress database and executed whenever a user (including other Administrators) visits a page where the progress bar is rendered.

2. Attack Vector Analysis

  • Vulnerable Endpoint: wp-admin/options.php (The standard WordPress settings handler).
  • Vulnerable Parameter: rp_settings[rp_field_custom_position], rp_settings[rp_field_fg_color], or rp_settings[rp_field_bg_color].
  • Authentication Level: Administrator (capability manage_options).
  • Preconditions: The plugin must be active. To trigger the frontend XSS, the progress bar must be enabled for at least one template (e.g., the homepage).

3. Code Flow

  1. Input: An administrator submits the settings form at wp-admin/options-general.php?page=reading-progressbar.
  2. Processing (Admin): admin/rp-admin.php registers the setting via register_setting( 'pluginPage', 'rp_settings' ); (Line 62). It defines no sanitization callback.
  3. Storage: WordPress saves the raw, unsanitized array into the rp_settings option in the wp_options table.
  4. Admin Rendering: admin/rp-admin.php renders the settings fields. In rp_field_custom_position_render() (Line 197), the value is echoed directly:
    <input type='text' name='rp_settings[rp_field_custom_position]' value='<?php echo $optionCustomPosition; ?>'>
    
  5. Public Rendering: public/rp-public.php hooks into wp_footer via rp_show_it() (Line 58). It retrieves the settings and echoes them into a <progress> element without escaping:
    echo '<progress class="readingProgressbar" 
        data-height="' . $rpHeight . '" 
        data-position="'. $rpPosition .'" 
        data-custom-position="'. $rpCustomPosition .'" 
        ...';
    

4. Nonce Acquisition Strategy

The vulnerability is exploited via the standard WordPress Settings API. To update settings, a nonce for the specific settings group (pluginPage) is required.

  1. Navigate: Use browser_navigate to go to wp-admin/options-general.php?page=reading-progressbar.
  2. Extract: Use browser_eval to retrieve the nonce and the referer from the form.
    const nonce = document.querySelector('input[name="_wpnonce"]').value;
    const referer = document.querySelector('input[name="_wp_http_referer"]').value;
    return { nonce, referer };
    
  3. Note: This is an authenticated action. The agent must be logged in as an Administrator.

5. Exploitation Strategy

  1. Login: Log in as an Administrator.
  2. Configure Plugin: Ensure the progress bar is configured to show on the homepage.
  3. Obtain Nonce: Navigate to the plugin settings page and extract the _wpnonce for the pluginPage group.
  4. Inject Payload: Send a POST request to wp-admin/options.php with the malicious payload.
    • URL: https://<target>/wp-admin/options.php
    • Content-Type: application/x-www-form-urlencoded
    • Parameters:
      • option_page: pluginPage
      • action: update
      • _wpnonce: [EXTRACTED_NONCE]
      • _wp_http_referer: /wp-admin/options-general.php?page=reading-progressbar
      • rp_settings[rp_field_height]: 5
      • rp_settings[rp_field_position]: custom
      • rp_settings[rp_field_custom_position]: "><script>alert(document.domain)</script>
      • rp_settings[rp_field_templates][home]: 1 (Enable on homepage)
  5. Trigger: Navigate to the site homepage.

6. Test Data Setup

  1. Plugin Activation: wp plugin activate reading-progress-bar
  2. Post Creation: Ensure there is at least one post or the homepage is accessible so the progress bar has a target to render on.
    • wp post create --post_type=page --post_title="Home" --post_status=publish
    • wp option update show_on_front page
    • wp option update page_on_front $(wp post list --post_type=page --post_title="Home" --field=ID)

7. Expected Results

  • Response: The POST to options.php should return a 302 Redirect back to the settings page with settings-updated=true.
  • Frontend Impact: Upon visiting the homepage, the HTML source will contain:
    <progress class="readingProgressbar" ... data-custom-position=""><script>alert(document.domain)</script>" ...>
    
  • Execution: A JavaScript alert box showing the domain will appear.

8. Verification Steps

  1. Check DB: Verify the stored option via WP-CLI:
    wp option get rp_settings --format=json
    
    Confirm the rp_field_custom_position key contains the script tag.
  2. Check Admin Page: Navigate to wp-admin/options-general.php?page=reading-progressbar and inspect the source of the "Target fixed HTML element" input field. It should show the attribute breakout.

9. Alternative Approaches

  • Alternative Field: If rp_field_custom_position is sanitized in some environments, try rp_field_fg_color or rp_field_bg_color.
  • Admin-Only XSS: Even if the frontend fails to render (e.g., due to theme incompatibility), the XSS will still fire on the Settings Page itself when the administrator views it, because rp_field_custom_position_render also echoes the value unsanitized into the value attribute. Use payload: ' onmouseover='alert(1) '.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Reading progressbar plugin for WordPress is vulnerable to Stored Cross-Site Scripting (XSS) in versions up to 1.3.1. This occurs because the plugin fails to sanitize and escape settings such as custom position and colors before storing them and rendering them in both the admin dashboard and the public frontend, allowing administrators to inject arbitrary JavaScript.

Vulnerable Code

// admin/rp-admin.php (v1.3)
function rp_field_height_render(  ) {
	$options = get_option( 'rp_settings' );
	if (isset($options['rp_field_height'])) {
		$optionHeight = $options['rp_field_height'];
	} else {
		$optionHeight = '';		
	}
	?>
	<input type='number' name='rp_settings[rp_field_height]' value='<?php echo $optionHeight; ?>'>
	<?php
}

// ... lines 197-199
function rp_field_custom_position_render(  ) {
    // ...
	<input type='text' name='rp_settings[rp_field_custom_position]' value='<?php echo $optionCustomPosition; ?>'>

---

// public/rp-public.php (v1.3)
function rp_show_it() {
    // ... retrieves $rpHeight, $rpPosition, $rpCustomPosition, etc.
    if ( isset($optionTemplates['home']) && (is_home() && is_front_page() || is_front_page()) ) {
        echo '<progress class="readingProgressbar" 
            data-height="' . $rpHeight . '" 
            data-position="'. $rpPosition .'" 
            data-custom-position="'. $rpCustomPosition .'" 
            data-foreground="' . $rpForegroundColor . '" 
            data-background="' . $rpBackgroundColor . '" 
            value="0"></progress>';
    }
    // ... (logic repeated for other template types)
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3/admin/rp-admin.php /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3.1/admin/rp-admin.php
--- /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3/admin/rp-admin.php	2016-12-28 16:17:40.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3.1/admin/rp-admin.php	2026-02-18 12:09:20.000000000 +0000
@@ -132,7 +132,7 @@
 		$optionHeight = '';		
 	}
 	?>
-	<input type='number' name='rp_settings[rp_field_height]' value='<?php echo $optionHeight; ?>'>
+	<input type='number' name='rp_settings[rp_field_height]' value='<?php echo esc_attr( $optionHeight ); ?>'>
 	<?php
 }
 
@@ -145,7 +145,7 @@
 		$optionForegroundColor = '';		
 	}
 	?>
-	<input type='text' class='rp-colorpicker' name='rp_settings[rp_field_fg_color]' value='<?php echo $optionForegroundColor; ?>'>
+	<input type='text' class='rp-colorpicker' name='rp_settings[rp_field_fg_color]' value='<?php echo esc_attr( $optionForegroundColor ); ?>'>
 	<?php
 }
 
@@ -157,7 +157,7 @@
 		$optionBackgroundColor = '';		
 	}
 	?>
-	<input type='text' class='rp-colorpicker' name='rp_settings[rp_field_bg_color]' value='<?php echo $optionBackgroundColor; ?>'>
+	<input type='text' class='rp-colorpicker' name='rp_settings[rp_field_bg_color]' value='<?php echo esc_attr( $optionBackgroundColor ); ?>'>
 	<?php
 }
 
@@ -186,7 +186,7 @@
 		$optionCustomPosition = '';		
 	}
 	?>
-	<input type='text' name='rp_settings[rp_field_custom_position]' value='<?php echo $optionCustomPosition; ?>'>
+	<input type='text' name='rp_settings[rp_field_custom_position]' value='<?php echo esc_attr( $optionCustomPosition ); ?>'>
 	<p class="description"><?php echo __('Note: use it only if you have selected <b>custom</b> position before, instead of <b>top</b> or <b>bottom</b>', 'reading-progress-bar'); ?></p>
 	<?php
 }
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3/public/rp-public.php /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3.1/public/rp-public.php
--- /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3/public/rp-public.php	2021-07-01 15:10:50.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/reading-progress-bar/1.3.1/public/rp-public.php	2026-02-18 12:09:20.000000000 +0000
@@ -62,38 +62,38 @@
 				$optionTemplates = $rpSettings['rp_field_templates'];
 				if ( isset($optionTemplates['home']) && (is_home() && is_front_page() || is_front_page()) ) {
 					echo '<progress class="readingProgressbar" 
-						data-height="' . $rpHeight . '" 
-						data-position="'. $rpPosition .'" 
-						data-custom-position="'. $rpCustomPosition .'" 
-						data-foreground="' . $rpForegroundColor . '" 
-						data-background="' . $rpBackgroundColor . '" 
+						data-height="' . esc_attr( $rpHeight ) . '" 
+						data-position="'. esc_attr( $rpPosition ) .'" 
+						data-custom-position="'. esc_attr( $rpCustomPosition ) .'" 
+						data-foreground="' . esc_attr( $rpForegroundColor ) . '" 
+						data-background="' . esc_attr( $rpBackgroundColor ) . '" 
 						value="0"></progress>';
 				} elseif ( isset($optionTemplates['blog']) && (is_home() && !is_front_page()) ) {
 					echo '<progress class="readingProgressbar" 
-						data-height="' . $rpHeight . '" 
-						data-position="'. $rpPosition .'" 
-						data-custom-position="'. $rpCustomPosition .'" 
-						data-foreground="' . $rpForegroundColor . '" 
-						data-background="' . $rpBackgroundColor . '" 
+						data-height="' . esc_attr( $rpHeight ) . '" 
+						data-position="'. esc_attr( $rpPosition ) .'" 
+						data-custom-position="'. esc_attr( $rpCustomPosition ) .'" 
+						data-foreground="' . esc_attr( $rpForegroundColor ) . '" 
+						data-background="' . esc_attr( $rpBackgroundColor ) . '" 
 						value="0"></progress>';
 				} elseif ( isset($optionTemplates['archive']) && (is_archive()) ) {
 					echo '<progress class="readingProgressbar" 
-						data-height="' . $rpHeight . '" 
-						data-position="'. $rpPosition .'" 
-						data-custom-position="'. $rpCustomPosition .'" 
-						data-foreground="' . $rpForegroundColor . '" 
-						data-background="' . $rpBackgroundColor . '" 
+						data-height="' . esc_attr( $rpHeight ) . '" 
+						data-position="'. esc_attr( $rpPosition ) .'" 
+						data-custom-position="'. esc_attr( $rpCustomPosition ) .'" 
+						data-foreground="' . esc_attr( $rpForegroundColor ) . '" 
+						data-background="' . esc_attr( $rpBackgroundColor ) . '" 
 						value="0"></progress>';
 				} elseif ( isset($optionTemplates['single']) && (is_singular() && !is_front_page()) ) {
 					$optionPostTypes = $rpSettings['rp_field_posttypes'];
 					$currentPostType = get_post_type();
 					if (isset($optionPostTypes[$currentPostType])) {
 						echo '<progress class="readingProgressbar" 
-							data-height="' . $rpHeight . '" 
-							data-position="'. $rpPosition .'" 
-							data-custom-position="'. $rpCustomPosition .'" 
-							data-foreground="' . $rpForegroundColor . '" 
-							data-background="' . $rpBackgroundColor . '" 
+							data-height="' . esc_attr( $rpHeight ) . '" 
+							data-position="'. esc_attr( $rpPosition ) .'" 
+							data-custom-position="'. esc_attr( $rpCustomPosition ) .'" 
+							data-foreground="' . esc_attr( $rpForegroundColor ) . '" 
+							data-background="' . esc_attr( $rpBackgroundColor ) . '" 
 							value="0"></progress>';
 					} 
 				}

Exploit Outline

To exploit this vulnerability, an attacker with Administrator privileges needs to: 1. Log in to the WordPress dashboard and navigate to the Reading Progressbar settings page at `wp-admin/options-general.php?page=reading-progressbar`. 2. Capture the security nonce (`_wpnonce`) for the `pluginPage` setting group from the page source. 3. Send a POST request to `wp-admin/options.php` to update the `rp_settings` option. The payload should include a malicious script in the `rp_settings[rp_field_custom_position]` parameter (e.g., `"><script>alert(document.domain)</script>`). 4. Ensure the progress bar is enabled for at least one public template (like the homepage) in the same POST request. 5. The script will execute whenever the administrator visits the settings page again or when any user visits the homepage.

Check if your site is affected.

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