RestroPress <= 3.2.8.6 - Authenticated (Contributor+) Stored Cross-Site Scripting
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:NTechnical Details
<=3.2.8.6What Changed in the Fix
Changes introduced in v3.2.8.6.1
Source Code
WordPress.org SVN# 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_keymust be empty to trigger the error message display block where the XSS sink resides.
3. Code Flow
- Entry Point:
RP_Shortcodes::init()(inincludes/class-rpress-shortcodes.php) registers therpress_receiptshortcode, mapping it toRP_Shortcodes::rpress_receipt. - Attribute Processing: In
RP_Shortcodes::rpress_receipt, the user-
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
@@ -321,38 +321,38 @@ $dinein_count = ' <span class="count">(' . $this->dinein_count . ')</span>'; $out_for_deliver_count = ' <span class="count">(' . $this->out_for_deliver_count . ')</span>'; $trash_count = ' <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 ; @@ -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.