From 4156adc139da00afb984222d9a86fef45727c173 Mon Sep 17 00:00:00 2001 From: milan88888 Date: Mon, 7 Sep 2026 17:28:48 +0545 Subject: [PATCH 1/5] #1408 Fix - Prevent membership privilege escalation and open redirect after login --- includes/Functions/CoreFunctions.php | 123 ++++++++++++++++ includes/frontend/class-ur-frontend.php | 36 +++++ includes/functions-ur-core.php | 12 +- modules/membership/includes/AJAX.php | 29 ++++ modules/membership/includes/Admin.php | 131 ++++++++++-------- .../Admin/Services/MembersService.php | 9 +- .../Admin/Services/MembershipService.php | 116 ++++++++++++++++ .../Admin/Services/SubscriptionService.php | 24 +--- 8 files changed, 404 insertions(+), 76 deletions(-) diff --git a/includes/Functions/CoreFunctions.php b/includes/Functions/CoreFunctions.php index 480e9bdc2..528eea528 100644 --- a/includes/Functions/CoreFunctions.php +++ b/includes/Functions/CoreFunctions.php @@ -95,6 +95,129 @@ function ur_membership_verify_nonce( $nonce ) { } } +if ( ! function_exists( 'ur_membership_get_privileged_capabilities' ) ) { + /** + * Capabilities that make a role unsafe to grant automatically through a membership. + * + * The stock editor role holds unfiltered_html and WooCommerce's shop_manager holds list_users, + * so neither is listed here: both are roles a site may legitimately attach to a plan. + * + * @since 5.2.8 + * + * @return array List of capability names. + */ + function ur_membership_get_privileged_capabilities() { + /** + * Filters the capabilities that bar a role from being granted through a membership. + * + * @since 5.2.8 + * + * @param array $capabilities List of capability names. + */ + return apply_filters( + 'user_registration_membership_privileged_capabilities', + array( + 'manage_options', + 'promote_users', + 'edit_users', + 'create_users', + 'delete_users', + 'remove_users', + 'install_plugins', + 'activate_plugins', + 'update_plugins', + 'edit_plugins', + 'install_themes', + 'switch_themes', + 'edit_themes', + 'edit_files', + 'edit_dashboard', + ) + ); + } +} + +if ( ! function_exists( 'ur_membership_is_privileged_role' ) ) { + /** + * Whether a role holds a capability that makes it unsafe to grant through a membership. + * + * @since 5.2.8 + * + * @param string $role Role slug. + * @return bool True when the role is too privileged to grant automatically. + */ + function ur_membership_is_privileged_role( $role ) { + $role_object = wp_roles()->get_role( sanitize_key( $role ) ); + + if ( ! $role_object ) { + return false; + } + + foreach ( ur_membership_get_privileged_capabilities() as $capability ) { + if ( ! empty( $role_object->capabilities[ $capability ] ) ) { + return true; + } + } + + return false; + } +} + +if ( ! function_exists( 'ur_membership_get_safe_role' ) ) { + /** + * Constrain the role a membership grants, as a backstop at the point of assignment. + * + * A plan's role is administrator-authored data, so a privileged value is honoured when the + * plan was authored by someone entitled to assign roles, and refused otherwise. That keeps a + * plan injected by a lower role from granting itself anything, without overriding a choice an + * administrator deliberately made. Every refusal is logged, because a silent downgrade reads + * as the membership simply not working. + * + * @since 5.2.8 + * + * @param string $role Role slug taken from the membership data. + * @param int $membership_id Membership post ID the role came from. + * @param string $fallback Role used when the requested one is missing or refused. + * @return string Role slug safe to grant. + */ + function ur_membership_get_safe_role( $role, $membership_id = 0, $fallback = 'subscriber' ) { + $role = sanitize_key( $role ); + $safe = $role; + + if ( empty( $role ) || ! wp_roles()->is_role( $role ) ) { + $safe = $fallback; + } elseif ( ur_membership_is_privileged_role( $role ) ) { + $author_id = $membership_id ? (int) get_post_field( 'post_author', absint( $membership_id ) ) : 0; + + if ( ! $author_id || ! user_can( $author_id, 'promote_users' ) ) { + $safe = $fallback; + + ur_get_logger()->warning( + sprintf( + /* translators: 1: requested role slug, 2: membership ID, 3: role granted instead. */ + 'Refused to grant privileged role "%1$s" from membership %2$d because its author cannot assign roles; granted "%3$s" instead.', + $role, + absint( $membership_id ), + $fallback + ), + array( 'source' => 'user-registration-membership' ) + ); + } + } + + /** + * Filters the role a membership grants, after the privilege backstop has run. + * + * @since 5.2.8 + * + * @param string $safe Role slug that will be granted. + * @param string $role Role slug requested by the membership data. + * @param int $membership_id Membership post ID the role came from. + */ + return apply_filters( 'user_registration_membership_safe_role', $safe, $role, $membership_id ); + } +} + if ( ! function_exists( 'ur_membership_get_currencies' ) ) { /** * ur_membership_get_currencies diff --git a/includes/frontend/class-ur-frontend.php b/includes/frontend/class-ur-frontend.php index 634304cc6..79f20a751 100644 --- a/includes/frontend/class-ur-frontend.php +++ b/includes/frontend/class-ur-frontend.php @@ -45,6 +45,7 @@ public function __construct() { add_filter( 'user_registration_my_account_shortcode', array( $this, 'user_registration_my_account_layout' ) ); add_filter( 'user_registration_before_save_profile_details', array( $this, 'user_registration_before_save_profile_details' ), 10, 3 ); add_filter( 'user_registration_login_redirect', array( $this, 'login_redirect' ), 10, 2 ); + add_filter( 'allowed_redirect_hosts', array( $this, 'allow_custom_redirect_host' ) ); add_filter( 'user_registration_redirect_after_logout', array( $this, 'logout_redirect' ), 10, 1 ); add_action( 'init', array( $this, 'ur_register_payment_tab_if_eligible' ) ); } @@ -233,6 +234,41 @@ public function login_redirect( $redirect, $user ) { } return apply_filters( 'user_registration_login_redirect_url', $redirect, $user, $redirect_option ); } + /** + * Allow the admin-configured external login redirect host through wp_validate_redirect(). + * + * The after-login redirect is validated against the allowed hosts, so the External URL set + * in Login Options has to be listed or that setting would fall back to the home page. + * + * @since 5.2.8 + * + * @param string[] $hosts Allowed redirect host names. + * @return string[] Allowed redirect host names. + */ + public function allow_custom_redirect_host( $hosts ) { + if ( ! ur_string_to_bool( get_option( 'user_registration_login_options_enable_custom_redirect', false ) ) ) { + return $hosts; + } + + if ( 'external-url' !== get_option( 'user_registration_login_options_redirect_after_login', 'no-redirection' ) ) { + return $hosts; + } + + $external_url = get_option( 'user_registration_login_options_after_login_redirect_external_url', '' ); + + if ( empty( $external_url ) || ! ur_is_valid_url( $external_url ) ) { + return $hosts; + } + + $host = wp_parse_url( $external_url, PHP_URL_HOST ); + + if ( ! empty( $host ) ) { + $hosts[] = $host; + } + + return $hosts; + } + public function logout_redirect( $redirect ) { if ( ! ur_string_to_bool( get_option( 'user_registration_login_options_enable_custom_redirect', false ) ) ) { return $redirect; diff --git a/includes/functions-ur-core.php b/includes/functions-ur-core.php index 1a829b598..9b4a68b3d 100644 --- a/includes/functions-ur-core.php +++ b/includes/functions-ur-core.php @@ -5401,11 +5401,14 @@ function ur_process_login( $nonce_value ) { */ $redirect = apply_filters( 'user_registration_login_redirect', $redirect, $user ); + // Validate above the branch: the AJAX response is navigated to client side, so a fix on the header redirect alone would not cover it. + $redirect = wp_validate_redirect( $redirect, get_home_url() ); + if ( ur_is_ajax_login_enabled() && empty( $_POST['resubmitted'] ) ) { // phpcs:ignore wp_send_json_success( array( 'message' => $redirect ) ); wp_send_json( $user ); } else { - wp_redirect( wp_validate_redirect( $redirect, $redirect ) ); // phpcs:ignore + wp_safe_redirect( $redirect ); exit; } @@ -5459,9 +5462,12 @@ function ( $err_msg ) use ( $message ) { */ do_action( 'user_registration_login_failed' ); - $redirect_url = wp_get_raw_referer() ? wp_get_raw_referer() : ur_get_my_account_url(); + $referer = wp_get_raw_referer(); + + // wp_validate_redirect() returns an empty string for empty input, not the fallback, so guard it explicitly. + $redirect_url = $referer ? wp_validate_redirect( $referer, ur_get_my_account_url() ) : ur_get_my_account_url(); $redirect_url = add_query_arg( 'urm_error', $error_key, $redirect_url ); - wp_redirect( $redirect_url ); + wp_safe_redirect( $redirect_url ); exit; } diff --git a/modules/membership/includes/AJAX.php b/modules/membership/includes/AJAX.php index 61742e5f4..b3615e9fd 100644 --- a/modules/membership/includes/AJAX.php +++ b/modules/membership/includes/AJAX.php @@ -1818,6 +1818,16 @@ public static function upgrade_membership() { ); } + // Only a plan the site currently offers may be selected. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by ur_membership_verify_nonce() at the top of this handler. + if ( ! ( new MembershipService() )->is_membership_purchasable( absint( $_POST['selected_membership_id'] ) ) ) { + wp_send_json_error( + array( + 'message' => __( 'Invalid membership selected.', 'user-registration' ), + ) + ); + } + if ( isset( $_POST['form_data'] ) && ! empty( $_POST['form_data'] ) ) { $single_field = array(); $form_data = json_decode( wp_unslash( $_POST['form_data'] ) ); @@ -2075,6 +2085,16 @@ public static function add_multiple_membership() { ); } + // Only a plan the site currently offers may be selected. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by ur_membership_verify_nonce() at the top of this handler. + if ( ! ( new MembershipService() )->is_membership_purchasable( absint( $_POST['selected_membership_id'] ) ) ) { + wp_send_json_error( + array( + 'message' => __( 'Invalid membership selected.', 'user-registration' ), + ) + ); + } + if ( isset( $_POST['form_data'] ) && ! empty( $_POST['form_data'] ) ) { $single_field = array(); $form_data = json_decode( wp_unslash( $_POST['form_data'] ) ); @@ -2205,6 +2225,15 @@ function ( $user_memberships ) { $membership_meta = json_decode( wp_unslash( $membership_data['meta_value'] ), true ); $membership_type = $membership_meta['type'] ?? 'unknown'; // free, paid, or subscription + // Reject a payment method the plan does not accept: 'free' on a paid plan would grant the plan's role at once, unpaid. + if ( ! ( new MembershipService() )->is_valid_payment_method_for_membership( $membership_meta, $data['payment_method'], $data ) ) { + wp_send_json_error( + array( + 'message' => __( 'Invalid payment method for this membership.', 'user-registration' ), + ) + ); + } + $payment_gateway = $data['payment_method'] ?? 'unknown'; $member_id = get_current_user_id(); $member = get_userdata( $member_id ); diff --git a/modules/membership/includes/Admin.php b/modules/membership/includes/Admin.php index 7eeb36576..6c9e55ec6 100644 --- a/modules/membership/includes/Admin.php +++ b/modules/membership/includes/Admin.php @@ -129,6 +129,7 @@ private function __construct() { add_action( 'init', array( $this, 'includes' ) ); add_action( 'init', array( $this, 'create_membership_post_type' ), 0 ); add_action( 'init', array( $this, 'create_membership_groups_post_type' ), 0 ); + add_action( 'init', array( $this, 'protect_membership_post_meta' ), 0 ); add_action( 'init', array( 'WPEverest\URMembership\ShortCodes', 'init' ) ); add_action( 'init', array( $this, 'add_membership_options' ) ); add_action( 'plugins_loaded', array( $this, 'include_membership_payment_files' ) ); @@ -555,35 +556,9 @@ public function process_membership_after_registration( $success_params, $valid_f $payment_gateway = $data['payment_method'] ?? 'unknown'; // Reject attacker-supplied payment_method values that don't match the membership. - // A paid/subscription membership must use one of its configured gateways; 'free' - // is never a valid gateway for a non-free membership. - if ( 'free' !== $membership_type ) { - if ( 'free' === $data['payment_method'] ) { - // UR-4386: 'free' on a paid plan is only valid when a 100% coupon zeroes a - // one-time plan (free order, no gateway). Re-validate server-side; reject a forge. - if ( ! $this->is_full_discount_free_order( $membership_type, $membership_meta, $data ) ) { - wp_delete_user( absint( $member_id ) ); - wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) ); - } - } else { - $configured_gateways = array(); - if ( ! empty( $membership_meta['payment_gateways'] ) && is_array( $membership_meta['payment_gateways'] ) ) { - foreach ( $membership_meta['payment_gateways'] as $gw_key => $gw_data ) { - if ( isset( $gw_data['status'] ) && 'on' === $gw_data['status'] ) { - $configured_gateways[] = $gw_key; - } - } - } - // Also include globally active gateways (Settings > Payments) so that - // gateways enabled site-wide are accepted even if not per-membership saved. - $global_gateways = array_keys( urm_get_all_active_payment_gateways( $membership_type ) ); - $configured_gateways = array_unique( array_merge( $configured_gateways, $global_gateways ) ); - - if ( ! empty( $configured_gateways ) && ! in_array( $data['payment_method'], $configured_gateways, true ) ) { - wp_delete_user( absint( $member_id ) ); - wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) ); - } - } + if ( ! ( new MembershipService() )->is_valid_payment_method_for_membership( $membership_meta, $data['payment_method'], $data ) ) { + wp_delete_user( absint( $member_id ) ); + wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) ); } // PaymentGatewayLogging — session start + form submission @@ -775,32 +750,6 @@ private function set_login_gate_for_pending_member( $member_id ) { update_user_meta( $member_id, 'ur_user_status', \UR_Admin_User_Manager::PENDING ); } } - /* - * Whether a payment_method="free" submission on a non-free plan is a legitimate 100%-coupon - * free order. Only ONE-TIME (paid) plans qualify; the coupon is re-validated against the DB - * so a forged payment_method="free" cannot bypass payment. UR-4386. - * - * @param string $membership_type Membership type (free|paid|subscription). - * @param array $membership_meta Membership meta. - * @param array $data Submitted registration data. - * @return bool - */ - private function is_full_discount_free_order( $membership_type, $membership_meta, $data ) { - if ( 'paid' !== $membership_type || empty( $data['coupon'] ) || ! ur_check_module_activation( 'coupon' ) ) { - return false; - } - $coupon_details = ur_get_coupon_details( sanitize_text_field( $data['coupon'] ) ); - if ( empty( $coupon_details ) || empty( $coupon_details['coupon_status'] ) ) { - return false; - } - $plan_amount = floatval( $membership_meta['amount'] ?? 0 ); - $discount_type = $coupon_details['coupon_discount_type'] ?? 'fixed'; - $discount_value = floatval( $coupon_details['coupon_discount'] ?? 0 ); - $discount = ( 'percent' === $discount_type ) ? ( $plan_amount * $discount_value / 100 ) : $discount_value; - - return ( $plan_amount > 0 ) && ( 0.0 === round( max( 0, $plan_amount - $discount ), 2 ) ); - } - public function update_redirect_url_for_membership( $redirect_url, $form_id ) { $thank_you_page_id = get_option( 'user_registration_thank_you_page_id' ); $login_option = ur_get_form_setting_by_key( $form_id, 'user_registration_form_setting_login_options' ); @@ -920,6 +869,7 @@ public function create_membership_post_type() { 'show_ui' => true, 'capability_type' => 'post', 'map_meta_cap' => true, + 'capabilities' => $this->get_membership_post_type_capabilities(), 'show_in_menu' => false, 'hierarchical' => false, 'rewrite' => false, @@ -970,6 +920,7 @@ public function create_membership_groups_post_type() { 'show_ui' => true, 'capability_type' => 'post', 'map_meta_cap' => true, + 'capabilities' => $this->get_membership_post_type_capabilities(), 'show_in_menu' => false, 'hierarchical' => false, 'rewrite' => false, @@ -982,6 +933,76 @@ public function create_membership_groups_post_type() { ); } + /** + * Capability map for the membership post types. + * + * A membership plan stores the role a member is granted on purchase, so authoring one is an + * administrative act. Mapping every primitive post capability to manage_options keeps lower + * roles out of post-new.php and post.php for these types, and because add_post_meta maps + * through edit_post it also keeps them out of core's custom-fields write path. + * + * @since 5.2.8 + * + * @return array Capability map for register_post_type(). + */ + private function get_membership_post_type_capabilities() { + return array( + 'edit_posts' => 'manage_options', + 'edit_others_posts' => 'manage_options', + 'edit_published_posts' => 'manage_options', + 'edit_private_posts' => 'manage_options', + 'publish_posts' => 'manage_options', + 'read_private_posts' => 'manage_options', + 'create_posts' => 'manage_options', + 'delete_posts' => 'manage_options', + 'delete_private_posts' => 'manage_options', + 'delete_published_posts' => 'manage_options', + 'delete_others_posts' => 'manage_options', + ); + } + + /** + * Protect the membership post meta from core's custom-fields write path. + * + * These keys hold the plan configuration, including the granted role, and are not prefixed + * with an underscore, so is_protected_meta() does not cover them. An auth_callback keeps + * core's add-meta handler out even if a site widens the post type capabilities through the + * registration filters. The plugin's own update_post_meta() calls are unaffected. + * + * @since 5.2.8 + */ + public function protect_membership_post_meta() { + $protected_meta = array( + 'ur_membership' => array( 'ur_membership', 'ur_membership_description' ), + 'ur_membership_groups' => array( 'urmg_memberships', 'urmg_mode', 'urmg_upgrade_type', 'urmg_upgrade_path', 'urmg_default_group' ), + ); + + foreach ( $protected_meta as $post_type => $meta_keys ) { + foreach ( $meta_keys as $meta_key ) { + register_post_meta( + $post_type, + $meta_key, + array( + 'single' => true, + 'show_in_rest' => false, + 'auth_callback' => array( $this, 'can_manage_membership_meta' ), + ) + ); + } + } + } + + /** + * Whether the current user may write membership post meta. + * + * @since 5.2.8 + * + * @return bool True when the user can manage the site's options. + */ + public function can_manage_membership_meta() { + return current_user_can( 'manage_options' ); + } + /** * Adds the membership options to the database. * diff --git a/modules/membership/includes/Admin/Services/MembersService.php b/modules/membership/includes/Admin/Services/MembersService.php index 21255d646..bbadbe38b 100644 --- a/modules/membership/includes/Admin/Services/MembersService.php +++ b/modules/membership/includes/Admin/Services/MembersService.php @@ -236,10 +236,14 @@ public function update_user_meta( $data, $new_user_id ) { $user = new \WP_User( $new_user_id ); update_user_meta( $new_user_id, 'ur_registration_source', 'membership' ); + $membership_id = ! empty( $data['membership_data']['membership'] ) ? absint( $data['membership_data']['membership'] ) : ( ! empty( $data['membership_data']['ID'] ) ? absint( $data['membership_data']['ID'] ) : 0 ); + + // Backstop: a plan authored by a role that cannot assign roles must not grant a privileged one. + $data['role'] = ur_membership_get_safe_role( isset( $data['role'] ) ? $data['role'] : '', $membership_id ); + // UR-4573: Role handling on membership assignment. // UR-4710: Paid memberships are still pending here — defer the role until payment is confirmed (maybe_grant_pending_role()). if ( ! empty( $data['defer_role'] ) ) { - $membership_id = ! empty( $data['membership_data']['membership'] ) ? absint( $data['membership_data']['membership'] ) : ( ! empty( $data['membership_data']['ID'] ) ? absint( $data['membership_data']['ID'] ) : 0 ); update_user_meta( $new_user_id, 'urm_pending_role', @@ -278,6 +282,9 @@ public function maybe_grant_pending_role( $user_id ) { return; } + // Re-check on read: the pending role may have been stored before this backstop existed. + $pending['role'] = ur_membership_get_safe_role( $pending['role'], $pending['membership_id'] ); + $subscription_repository = new \WPEverest\URMembership\Admin\Repositories\MembersSubscriptionRepository(); $subscription = $subscription_repository->get_subscription_data_by_member_and_membership_id( $user_id, $pending['membership_id'] ); diff --git a/modules/membership/includes/Admin/Services/MembershipService.php b/modules/membership/includes/Admin/Services/MembershipService.php index a671f90d4..80242a7d9 100644 --- a/modules/membership/includes/Admin/Services/MembershipService.php +++ b/modules/membership/includes/Admin/Services/MembershipService.php @@ -98,6 +98,105 @@ public function list_active_memberships() { return apply_filters( 'build_membership_list_frontend', $memberships ); } + /** + * Whether a membership can be selected from the front end. + * + * Reuses the same active list the plan listings are built from, so a plan a site has hidden + * through build_membership_list_frontend cannot be purchased either. + * + * @since 5.2.8 + * + * @param int $membership_id Membership post ID. + * @return bool True when the membership is published and active. + */ + public function is_membership_purchasable( $membership_id ) { + $membership_id = absint( $membership_id ); + + if ( ! $membership_id ) { + return false; + } + + foreach ( $this->list_active_memberships() as $membership ) { + $id = isset( $membership['ID'] ) ? (int) $membership['ID'] : ( isset( $membership['id'] ) ? (int) $membership['id'] : 0 ); + + if ( $id === $membership_id ) { + return true; + } + } + + return false; + } + + /** + * Whether a payment method is one the membership actually accepts. + * + * Shared by the registration, additional-purchase and upgrade paths so the three cannot drift. + * + * @since 5.2.8 + * + * @param array $membership_meta Decoded plan meta. + * @param string $payment_method Submitted payment method. + * @param array $data Submitted purchase data, used for the full-discount check. + * @return bool True when the payment method is valid for this plan. + */ + public function is_valid_payment_method_for_membership( $membership_meta, $payment_method, $data = array() ) { + $membership_type = isset( $membership_meta['type'] ) ? $membership_meta['type'] : 'unknown'; + + if ( 'free' === $membership_type ) { + return true; + } + + // UR-4386: 'free' on a paid plan is only valid when a 100% coupon zeroes a one-time plan. + if ( 'free' === $payment_method ) { + return $this->is_full_discount_free_order( $membership_type, $membership_meta, $data ); + } + + $configured_gateways = array(); + + if ( ! empty( $membership_meta['payment_gateways'] ) && is_array( $membership_meta['payment_gateways'] ) ) { + foreach ( $membership_meta['payment_gateways'] as $gateway_key => $gateway_data ) { + if ( isset( $gateway_data['status'] ) && 'on' === $gateway_data['status'] ) { + $configured_gateways[] = $gateway_key; + } + } + } + + // Gateways enabled site-wide under Settings > Payments count as configured too. + $global_gateways = array_keys( urm_get_all_active_payment_gateways( $membership_type ) ); + $configured_gateways = array_unique( array_merge( $configured_gateways, $global_gateways ) ); + + return empty( $configured_gateways ) || in_array( $payment_method, $configured_gateways, true ); + } + + /** + * Whether a 100% coupon has zeroed a one-time paid plan, making a free order legitimate. + * + * @since 5.2.8 + * + * @param string $membership_type Membership type (free|paid|subscription). + * @param array $membership_meta Decoded plan meta. + * @param array $data Submitted purchase data. + * @return bool True when the order genuinely totals zero. + */ + private function is_full_discount_free_order( $membership_type, $membership_meta, $data ) { + if ( 'paid' !== $membership_type || empty( $data['coupon'] ) || ! ur_check_module_activation( 'coupon' ) ) { + return false; + } + + $coupon_details = ur_get_coupon_details( sanitize_text_field( $data['coupon'] ) ); + + if ( empty( $coupon_details ) || empty( $coupon_details['coupon_status'] ) ) { + return false; + } + + $plan_amount = floatval( $membership_meta['amount'] ?? 0 ); + $discount_type = $coupon_details['coupon_discount_type'] ?? 'fixed'; + $discount_value = floatval( $coupon_details['coupon_discount'] ?? 0 ); + $discount = ( 'percent' === $discount_type ) ? ( $plan_amount * $discount_value / 100 ) : $discount_value; + + return ( $plan_amount > 0 ) && ( 0.0 === round( max( 0, $plan_amount - $discount ), 2 ) ); + } + /** * Replace the old membership form shortcode with new registration form shortcode. * @@ -453,6 +552,23 @@ public function validate_membership_data( $data ) { return $result; } + $role = isset( $data['post_meta_data']['role'] ) ? sanitize_key( $data['post_meta_data']['role'] ) : ''; + + if ( ! empty( $role ) && ! wp_roles()->is_role( $role ) ) { + $result['status'] = false; + $result['message'] = esc_html__( 'The selected membership role does not exist.', 'user-registration' ); + + return $result; + } + + // A plan's role is granted automatically on purchase, so only someone who may assign roles can attach a privileged one. + if ( ! empty( $role ) && ur_membership_is_privileged_role( $role ) && ! current_user_can( 'promote_users' ) ) { + $result['status'] = false; + $result['message'] = esc_html__( 'Sorry, you are not allowed to assign that role to a membership.', 'user-registration' ); + + return $result; + } + /** * Filters the membership data validation result. * diff --git a/modules/membership/includes/Admin/Services/SubscriptionService.php b/modules/membership/includes/Admin/Services/SubscriptionService.php index a5a7724ab..39df61e14 100644 --- a/modules/membership/includes/Admin/Services/SubscriptionService.php +++ b/modules/membership/includes/Admin/Services/SubscriptionService.php @@ -519,23 +519,13 @@ public function upgrade_membership( $data ) { $selected_membership_details['membership'] = $data['selected_membership_id']; // Validate that the submitted payment method is one the destination membership actually supports. - if ( 'free' !== ( $selected_membership_details['type'] ?? '' ) ) { - $configured_gateways = array(); - if ( ! empty( $selected_membership_details['payment_gateways'] ) && is_array( $selected_membership_details['payment_gateways'] ) ) { - foreach ( $selected_membership_details['payment_gateways'] as $gw_key => $gw_data ) { - if ( isset( $gw_data['status'] ) && 'on' === $gw_data['status'] ) { - $configured_gateways[] = $gw_key; - } - } - } - if ( ! empty( $configured_gateways ) && ! in_array( $payment_method, $configured_gateways, true ) ) { - return array( - 'response' => array( - 'status' => false, - 'message' => __( 'Invalid payment method for this membership.', 'user-registration' ), - ), - ); - } + if ( ! ( new MembershipService() )->is_valid_payment_method_for_membership( $selected_membership_details, $payment_method, $data ) ) { + return array( + 'response' => array( + 'status' => false, + 'message' => __( 'Invalid payment method for this membership.', 'user-registration' ), + ), + ); } $selected_membership_details['payment_method'] = $payment_method; From 6e71d1edc3fc3b21fdd46a2eb6c3e7a02bec0d1a Mon Sep 17 00:00:00 2001 From: milan88888 Date: Tue, 8 Sep 2026 09:29:07 +0545 Subject: [PATCH 2/5] #1408 Fix - Address Copilot review: document meta auth_callback signature and drop dead AJAX-login code --- includes/functions-ur-core.php | 5 ----- modules/membership/includes/Admin.php | 12 ++++++++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/includes/functions-ur-core.php b/includes/functions-ur-core.php index 9b4a68b3d..f25aac79e 100644 --- a/includes/functions-ur-core.php +++ b/includes/functions-ur-core.php @@ -5406,15 +5406,10 @@ function ur_process_login( $nonce_value ) { if ( ur_is_ajax_login_enabled() && empty( $_POST['resubmitted'] ) ) { // phpcs:ignore wp_send_json_success( array( 'message' => $redirect ) ); - wp_send_json( $user ); } else { wp_safe_redirect( $redirect ); exit; } - - if ( ur_is_ajax_login_enabled() && empty( $_POST['resubmitted'] ) ) { // phpcs:ignore - wp_send_json( $user ); - } } } catch ( Exception $e ) { $status_code = $e->getCode(); diff --git a/modules/membership/includes/Admin.php b/modules/membership/includes/Admin.php index 6c9e55ec6..6b37750cb 100644 --- a/modules/membership/includes/Admin.php +++ b/modules/membership/includes/Admin.php @@ -995,12 +995,20 @@ public function protect_membership_post_meta() { /** * Whether the current user may write membership post meta. * + * Registered as a register_post_meta() auth_callback, which WordPress invokes with the + * filter arguments below. AND-ing with $allowed means this can only ever narrow a core + * decision, never widen one. + * * @since 5.2.8 * + * @param bool $allowed Whether the write is allowed so far. + * @param string $meta_key Meta key being written. + * @param int $object_id Post ID. + * @param int $user_id User attempting the write. * @return bool True when the user can manage the site's options. */ - public function can_manage_membership_meta() { - return current_user_can( 'manage_options' ); + public function can_manage_membership_meta( $allowed = false, $meta_key = '', $object_id = 0, $user_id = 0 ) { + return $allowed && current_user_can( 'manage_options' ); } /** From 20e777df098685b507beece72860a27a169d2a5e Mon Sep 17 00:00:00 2001 From: milan88888 Date: Tue, 8 Sep 2026 09:50:33 +0545 Subject: [PATCH 3/5] #1408 Fix - Match the meta auth_callback to WP's 6-arg filter signature and dedupe the redirect host --- includes/frontend/class-ur-frontend.php | 2 +- modules/membership/includes/Admin.php | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/includes/frontend/class-ur-frontend.php b/includes/frontend/class-ur-frontend.php index 79f20a751..917584830 100644 --- a/includes/frontend/class-ur-frontend.php +++ b/includes/frontend/class-ur-frontend.php @@ -262,7 +262,7 @@ public function allow_custom_redirect_host( $hosts ) { $host = wp_parse_url( $external_url, PHP_URL_HOST ); - if ( ! empty( $host ) ) { + if ( ! empty( $host ) && ! in_array( $host, $hosts, true ) ) { $hosts[] = $host; } diff --git a/modules/membership/includes/Admin.php b/modules/membership/includes/Admin.php index 6b37750cb..21ab40c5b 100644 --- a/modules/membership/includes/Admin.php +++ b/modules/membership/includes/Admin.php @@ -995,9 +995,9 @@ public function protect_membership_post_meta() { /** * Whether the current user may write membership post meta. * - * Registered as a register_post_meta() auth_callback, which WordPress invokes with the - * filter arguments below. AND-ing with $allowed means this can only ever narrow a core - * decision, never widen one. + * Registered as a register_post_meta() auth_callback. WordPress invokes it through the + * auth_{$object_type}_meta_{$meta_key} filter with the six arguments below; only the write + * decision it returns is used. Write access is limited to users who can manage options. * * @since 5.2.8 * @@ -1005,10 +1005,12 @@ public function protect_membership_post_meta() { * @param string $meta_key Meta key being written. * @param int $object_id Post ID. * @param int $user_id User attempting the write. + * @param string $cap Capability being checked. + * @param array $caps Primitive capabilities required. * @return bool True when the user can manage the site's options. */ - public function can_manage_membership_meta( $allowed = false, $meta_key = '', $object_id = 0, $user_id = 0 ) { - return $allowed && current_user_can( 'manage_options' ); + public function can_manage_membership_meta( $allowed = false, $meta_key = '', $object_id = 0, $user_id = 0, $cap = '', $caps = array() ) { + return current_user_can( 'manage_options' ); } /** From 66ae9c0cf16f6c13dedd861e6bea08529f283302 Mon Sep 17 00:00:00 2001 From: milan88888 Date: Tue, 8 Sep 2026 10:02:10 +0545 Subject: [PATCH 4/5] #1408 Fix - Evaluate the meta auth_callback against the user WordPress passes, not the current user --- modules/membership/includes/Admin.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/membership/includes/Admin.php b/modules/membership/includes/Admin.php index 21ab40c5b..074953d37 100644 --- a/modules/membership/includes/Admin.php +++ b/modules/membership/includes/Admin.php @@ -1007,10 +1007,11 @@ public function protect_membership_post_meta() { * @param int $user_id User attempting the write. * @param string $cap Capability being checked. * @param array $caps Primitive capabilities required. - * @return bool True when the user can manage the site's options. + * @return bool True when the evaluated user can manage the site's options. */ public function can_manage_membership_meta( $allowed = false, $meta_key = '', $object_id = 0, $user_id = 0, $cap = '', $caps = array() ) { - return current_user_can( 'manage_options' ); + // Check the user WordPress is evaluating, not the current request user, so the callback is correct when caps are tested for another user. + return user_can( $user_id, 'manage_options' ); } /** From 376e9decc9e772ba77e2a60f3f6cac315cf9926d Mon Sep 17 00:00:00 2001 From: milan88888 Date: Tue, 8 Sep 2026 10:25:41 +0545 Subject: [PATCH 5/5] #1408 Fix - Reject a forged gateway on a free membership so it cannot take the paid order path --- .../membership/includes/Admin/Services/MembershipService.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/membership/includes/Admin/Services/MembershipService.php b/modules/membership/includes/Admin/Services/MembershipService.php index 80242a7d9..ec1df256a 100644 --- a/modules/membership/includes/Admin/Services/MembershipService.php +++ b/modules/membership/includes/Admin/Services/MembershipService.php @@ -143,7 +143,9 @@ public function is_valid_payment_method_for_membership( $membership_meta, $payme $membership_type = isset( $membership_meta['type'] ) ? $membership_meta['type'] : 'unknown'; if ( 'free' === $membership_type ) { - return true; + // A free plan has no gateway, so only an empty or 'free' method is legitimate. Rejecting a + // forged gateway value keeps a free plan off the paid, deferred-role order path. + return '' === $payment_method || 'free' === $payment_method; } // UR-4386: 'free' on a paid plan is only valid when a 100% coupon zeroes a one-time plan.