CVE-2025-69017

RestroPress <= 3.2.8.6 - Authenticated (Contributor+) Stored Cross-Site Scripting

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

Description

The RestroPress plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to, and including, 3.2.8.6 due to insufficient input sanitization and output escaping. 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<=3.2.8.6
PublishedDecember 27, 2025
Last updatedApril 15, 2026
Affected pluginrestropress

What Changed in the Fix

Changes introduced in v3.2.8.6.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2025-69017 (RestroPress Stored XSS) ## 1. Vulnerability Summary The RestroPress plugin for WordPress is vulnerable to **Authenticated (Contributor+) Stored Cross-Site Scripting** due to insufficient output escaping on shortcode attributes. Specifically, the `rpress…

Show full research plan

Exploitation Research Plan: CVE-2025-69017 (RestroPress Stored XSS)

1. Vulnerability Summary

The RestroPress plugin for WordPress is vulnerable to Authenticated (Contributor+) Stored Cross-Site Scripting due to insufficient output escaping on shortcode attributes. Specifically, the rpress_receipt shortcode processes an error attribute that is concatenated directly into the HTML output without being passed through sanitization or escaping functions like esc_html(). This allows a user with Contributor-level privileges or higher to inject malicious JavaScript into a post or page.

2. Attack Vector Analysis

  • Endpoint: Frontend pages or posts where shortcodes are processed (typically any post/page content).
  • Vulnerable Shortcode: [rpress_receipt]
  • Vulnerable Attribute: error
  • Authentication: Required (Contributor+). Contributors can create posts and insert shortcodes, even if they cannot publish them.
  • Preconditions: The payment_key must be empty to trigger the error message display block where the XSS sink resides.

3. Code Flow

  1. Entry Point: RP_Shortcodes::init() (in includes/class-rpress-shortcodes.php) registers the rpress_receipt shortcode, mapping it to RP_Shortcodes::rpress_receipt.
  2. Attribute Processing: In RP_Shortcodes::rpress_receipt, the user-
Research Findings
Static analysis — not yet PoC-verified

Summary

The RestroPress plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'rpress_receipt' shortcode in versions up to 3.2.8.6. Authenticated attackers with contributor-level access can inject arbitrary JavaScript into the 'error' attribute, which is rendered without proper output escaping when a receipt cannot be retrieved.

Vulnerable Code

// includes/class-rpress-shortcodes.php

    // No key found
    if ( empty( $payment_key ) ) {
      return '<p class="rpress-alert rpress-alert-error">' . $rpress_receipt_args['error'] . '</p>';
    }

---

// includes/class-rpress-shortcodes.php

    if ( ! $user_can_view ) {
      return '<p class="rpress-alert rpress-alert-error">' . $rpress_receipt_args['error'] . '</p>';
    }

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6/includes/admin/payments/class-payments-table.php /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6.1/includes/admin/payments/class-payments-table.php
--- /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6/includes/admin/payments/class-payments-table.php	2026-04-11 16:16:48.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6.1/includes/admin/payments/class-payments-table.php	2026-04-14 11:16:00.000000000 +0000
@@ -321,38 +321,38 @@
 		$dinein_count     = '&nbsp;<span class="count">(' . $this->dinein_count    . ')</span>';
 		$out_for_deliver_count = '&nbsp;<span class="count">(' . $this->out_for_deliver_count . ')</span>';
 		$trash_count = '&nbsp;<span class="count">(' . $this->trash_count . ')</span>';
-		$views = array(
-			'all'        => sprintf( '<a href="%s"%s>%s</a>', remove_query_arg( array( 'status', 'paged','service-type' ) ), $current === 'all' || $current == '' ? ' class="current"' : '', __( 'All','restropress' ) . $total_count ),
-			'pending'    => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'status' => 'pending', 'paged' => FALSE ) ), $current === 'pending' ? ' class="current"' : '', __( 'Pending','restropress' ) . $pending_count ),
-			'paid'       => sprintf('<a href="%s"%s>%s</a>', add_query_arg( array( 'status' => 'publish', 'paged' => FALSE ) ), ( 'paid' === $current || 'publish' === $current ) ? ' class="current"' : '', __( 'Paid','restropress' ) . $paid_count ),
-			'processing' => sprintf('<a href="%s"%s>%s</a>', add_query_arg( array( 'status' => 'processing', 'paged' => FALSE ) ), $current === 'processing' ? ' class="current"' : '', __( 'Processing','restropress' ) . $out_for_deliver_count ),
-			'Trash'		 =>  sprintf('<a href="%s"%s>%s</a>', add_query_arg( array( 'status' => 'trash', 'paged' => FALSE ) ), $current === 'trash' ? ' class="current"' : '', __( 'Trash','restropress' ) . $trash_count),
-		
-		);
+		$views = array(
+			'all'        => sprintf( '<a href="%s"%s>%s</a>', esc_url( remove_query_arg( array( 'status', 'paged','service-type' ) ) ), $current === 'all' || $current == '' ? ' class="current"' : '', __( 'All','restropress' ) . $total_count ),
+			'pending'    => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'status' => 'pending', 'paged' => FALSE ) ) ), $current === 'pending' ? ' class="current"' : '', __( 'Pending','restropress' ) . $pending_count ),
+			'paid'       => sprintf('<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'status' => 'publish', 'paged' => FALSE ) ) ), ( 'paid' === $current || 'publish' === $current ) ? ' class="current"' : '', __( 'Paid','restropress' ) . $paid_count ),
+			'processing' => sprintf('<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'status' => 'processing', 'paged' => FALSE ) ) ), $current === 'processing' ? ' class="current"' : '', __( 'Processing','restropress' ) . $out_for_deliver_count ),
+			'Trash'		 =>  sprintf('<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'status' => 'trash', 'paged' => FALSE ) ) ), $current === 'trash' ? ' class="current"' : '', __( 'Trash','restropress' ) . $trash_count),
+		
+		);
 		$dinein_views = array();
 		if( is_plugin_active( 'restropress-dinein/restropress-dinein.php' ) ) {
   				$dinein_views = array(
-					'dinein' => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'service-type' => 'dinein', 'paged' => FALSE ) ), $get_services === 'dinein' ? ' class="current"' : '', __( 'Dinein','restropress' ) . $dinein_count ),
-				);
+					'dinein' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'service-type' => 'dinein', 'paged' => FALSE ) ) ), $get_services === 'dinein' ? ' class="current"' : '', __( 'Dinein','restropress' ) . $dinein_count ),
+				);
   		}
   		
 		if( $service_options === 'delivery_and_pickup' ) {
 			
 		$args =  array(
-					'delivery' => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'service-type' => 'delivery', 'paged' => FALSE ) ), $get_services === 'delivery' ? ' class="current"' : '', __( 'Delivery','restropress' ) . $delivery_count ),
-					'pickup'   => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'service-type' => 'pickup', 'paged' => FALSE ) ), $get_services === 'pickup' ? ' class="current"' : '', __( 'Pickup','restropress' ) . $pickup_count ),
-			);
-		}
-		elseif ( $service_options === 'delivery' ) {
-		$args =  array(
-					'delivery' => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'service-type' => 'delivery', 'paged' => FALSE ) ), $get_services === 'delivery' ? ' class="current"' : '', __( 'Delivery','restropress' ) . $delivery_count ),
-			);
-		}
-		elseif ( $service_options === 'pickup' ) {
-		$args =  array(
-					'pickup' => sprintf( '<a href="%s"%s>%s</a>', add_query_arg( array( 'service-type' => 'pickup', 'paged' => FALSE ) ), $get_services === 'pickup' ? ' class="current"' : '', __( 'Pickup','restropress' ) . $pickup_count ),
-			);
-		}
+					'delivery' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'service-type' => 'delivery', 'paged' => FALSE ) ) ), $get_services === 'delivery' ? ' class="current"' : '', __( 'Delivery','restropress' ) . $delivery_count ),
+					'pickup'   => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'service-type' => 'pickup', 'paged' => FALSE ) ) ), $get_services === 'pickup' ? ' class="current"' : '', __( 'Pickup','restropress' ) . $pickup_count ),
+			);
+		}
+		elseif ( $service_options === 'delivery' ) {
+		$args =  array(
+					'delivery' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'service-type' => 'delivery', 'paged' => FALSE ) ) ), $get_services === 'delivery' ? ' class="current"' : '', __( 'Delivery','restropress' ) . $delivery_count ),
+			);
+		}
+		elseif ( $service_options === 'pickup' ) {
+		$args =  array(
+					'pickup' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( array( 'service-type' => 'pickup', 'paged' => FALSE ) ) ), $get_services === 'pickup' ? ' class="current"' : '', __( 'Pickup','restropress' ) . $pickup_count ),
+			);
+		}
   		
   		return apply_filters( 'rpress_payments_table_views', array_merge( $views,$args,$dinein_views ) );
   	}
@@ -502,10 +502,10 @@
 		if ( ! empty( $search_terms ) ) {
 			$this->base_url = add_query_arg( 's', $search_terms, $this->base_url );
 		}
-		if ( rpress_is_payment_complete( $payment->ID ) && ! empty( $email ) ) {
-			$row_actions['email_links'] = '<a href="' . add_query_arg( array( 'rpress-action' => 'email_links', 'purchase_id' => $payment->ID ), $this->base_url ) . '">' . __( 'Resend Purchase Receipt', 'restropress' ) . '</a>';
-		}
-		$row_actions['delete'] = '<a href="' . wp_nonce_url( add_query_arg( array( 'rpress-action' => 'delete_payment', 'purchase_id' => $payment->ID ), $this->base_url ), 'rpress_payment_nonce' ) . '">' . __( 'Delete', 'restropress' ) . '</a>';
+		if ( rpress_is_payment_complete( $payment->ID ) && ! empty( $email ) ) {
+			$row_actions['email_links'] = '<a href="' . esc_url( add_query_arg( array( 'rpress-action' => 'email_links', 'purchase_id' => $payment->ID ), $this->base_url ) ) . '">' . __( 'Resend Purchase Receipt', 'restropress' ) . '</a>';
+		}
+		$row_actions['delete'] = '<a href="' . esc_url( wp_nonce_url( add_query_arg( array( 'rpress-action' => 'delete_payment', 'purchase_id' => $payment->ID ), $this->base_url ), 'rpress_payment_nonce' ) ) . '">' . __( 'Delete', 'restropress' ) . '</a>';
 		$row_actions = apply_filters( 'rpress_payment_row_actions', $row_actions, $payment );
 		if ( empty( $email ) ) {
 			$email = esc_html__( '( unknown )', 'restropress' );
@@ -576,11 +576,11 @@
 	    if ( rpress_get_option( 'enable_sequential' ) ) {
 	    	$alter_id = ( $rpress_number ) ? $rpress_number :  $payment->ID ;
 	    	$order_preview = '<a href="#" class="order-preview" data-order-id="' . absint( $payment->ID ) . '" title="' . esc_attr( __( 'Preview', 'restropress' ) ) . '"><span>' . esc_html( __( 'Preview', 'restropress' ) ) . '</span></a>
-	      <a class="" href="' . add_query_arg( 'id', $payment->ID, admin_url( 'admin.php?page=rpress-payment-history&view=view-order-details' ) ) . '">#' . $alter_id . ' ' . $customer_name . '</a><span class="rp-service-type badge-' . $service_type . ' ">' . rpress_service_label( $service_type ) . '</span>';
+	      <a class="" href="' . esc_url( add_query_arg( 'id', $payment->ID, admin_url( 'admin.php?page=rpress-payment-history&view=view-order-details' ) ) ) . '">#' . $alter_id . ' ' . $customer_name . '</a><span class="rp-service-type badge-' . $service_type . ' ">' . rpress_service_label( $service_type ) . '</span>';
 	    	return $order_preview . $actions ;
 	    } else {
 		    $order_preview = '<a href="#" class="order-preview" data-order-id="' . absint( $payment->ID ) . '" title="' . esc_attr( __( 'Preview', 'restropress' ) ) . '"><span>' . esc_html( __( 'Preview', 'restropress' ) ) . '</span></a>
-		    <a class="" href="' . add_query_arg( 'id', $payment->ID, admin_url( 'admin.php?page=rpress-payment-history&view=view-order-details' ) ) . '">#' . $prefix . $payment->ID . $postfix .' ' . $customer_name . '</a><span class="rp-service-type badge-' . $service_type . ' ">' . rpress_service_label( $service_type ) . '</span>';
+		    <a class="" href="' . esc_url( add_query_arg( 'id', $payment->ID, admin_url( 'admin.php?page=rpress-payment-history&view=view-order-details' ) ) ) . '">#' . $prefix . $payment->ID . $postfix .' ' . $customer_name . '</a><span class="rp-service-type badge-' . $service_type . ' ">' . rpress_service_label( $service_type ) . '</span>';
 		
 		}
 		    return $order_preview . $actions ;
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6/includes/class-rpress-shortcodes.php /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6.1/includes/class-rpress-shortcodes.php
--- /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6/includes/class-rpress-shortcodes.php	2026-04-11 16:16:48.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/restropress/3.2.8.6.1/includes/class-rpress-shortcodes.php	2026-04-14 11:16:00.000000000 +0000
@@ -142,9 +142,9 @@
     }
 
     // No key found
-    if ( empty( $payment_key ) ) {
-      return '<p class="rpress-alert rpress-alert-error">' . $rpress_receipt_args['error'] . '</p>';
-    }
+    if ( empty( $payment_key ) ) {
+      return '<p class="rpress-alert rpress-alert-error">' . esc_html( $rpress_receipt_args['error'] ) . '</p>';
+    }
 
     $payment_id    = rpress_get_purchase_id_by_key( $payment_key );
     $user_can_view = rpress_can_view_receipt( $payment_key );
@@ -174,9 +174,9 @@
      * Or if user is logged in and the user can view sensitive shop data
      *
      */
-    if ( ! $user_can_view ) {
-      return '<p class="rpress-alert rpress-alert-error">' . $rpress_receipt_args['error'] . '</p>';
-    }
+    if ( ! $user_can_view ) {
+      return '<p class="rpress-alert rpress-alert-error">' . esc_html( $rpress_receipt_args['error'] ) . '</p>';
+    }
     ob_start();
     rpress_get_template_part( 'shortcode', 'receipt' );
     $display = ob_get_clean();

Exploit Outline

The exploit is achieved by an attacker with at least Contributor-level privileges who can insert shortcodes into posts or pages. The attacker uses the `[rpress_receipt]` shortcode and provides a malicious JavaScript payload in the `error` attribute, such as `[rpress_receipt error="<script>alert('XSS')</script>"]`. To trigger the vulnerability, the attacker ensures that no valid `payment_key` is provided in the URL or within the shortcode, causing the plugin to execute the error logic that concatenates the raw `error` attribute value into the HTML response. When a user (including administrators) views the saved post, the script executes in their browser context.

Check if your site is affected.

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