Reported by: Karthik Ramakrishnan (via WPScan / Erwan Le Rousseau, Automattic). Affects: User Registration & Membership 5.2.7 and earlier. Verified against code on develop.
Two issues from the same report thread as #1423. Both reproduced by the reporter on 5.2.7 and confirmed against the code here.
Issue A - Subscriber can be granted any role through the membership purchase handler
Auth required: Subscriber. Impact: High - privilege escalation to any role a plan is mapped to, including Administrator, with no payment.
Summary
wp_ajax_user_registration_membership_add_multiple_membership (modules/membership/includes/AJAX.php:2066) verifies a nonce and nothing else. It does not validate:
- that the submitted
selected_pg is a payment method the plan accepts,
- that
selected_membership_id is an active plan,
- anything about the caller beyond "logged in".
The nonce is not a barrier: urm_upgrade_membership is localised to every front-end visitor of the plan listing (modules/membership/includes/Frontend/Frontend.php:200).
The submitted payment method then decides whether the plan's WordPress role is deferred.
modules/membership/includes/Admin/Services/MembershipService.php:136
$members_data['defer_role'] = ! empty( $members_data['membership_data']['payment_method'] ) && 'free' !== $members_data['membership_data']['payment_method'];
MembersService::update_user_meta() (:241) grants the role immediately when defer_role is falsy. So selected_pg=free on a paid plan grants the plan's role in the same request, while the order row is written pending and unpaid.
The registration path (modules/membership/includes/Admin.php:537-587) already validates plan and payment method against the plan's own config (added for UR-4386), and the upgrade path derives defer_role without the ! empty() prefix so it fails closed (SubscriptionService.php:636). The purchase handler is the one that does neither.
Two variants a narrow fix would miss
selected_pg= empty clears the same flag, because ! empty( ... ) && short-circuits before the 'free' comparison. Matching only the literal string free leaves this open.
selected_membership_id is never checked against post_content.status, so a deactivated plan (still publish, so MembershipRepository::get_single_membership_by_ID() returns it) can be purchased by id and its role granted.
defer_role did not exist before 5.2.7 - those releases grant the role for any payment method. The reporter reproduced that on 5.2.6 with an ordinary bank transfer, so the deferral is a partial guard over older behaviour rather than the origin. The handler first appears in 5.0.
Steps to reproduce
Site with the Membership module enabled, a paid plan whose Membership Role is above Subscriber, and a page carrying the plan listing shortcode.
# Log in as a Subscriber.
curl -s -c jar -o /dev/null \
-d "log=subscriber&pwd=PASSWORD&wp-submit=Log+In&testcookie=1" "$TARGET/wp-login.php"
# Admin-only screen not reachable yet.
curl -s -o /dev/null -w "before: %{http_code}\n" -b jar "$TARGET/wp-admin/users.php"
# before: 302
# Read the membership nonce off the public plan listing page.
NONCE=$(curl -sL -b jar "$TARGET/plans/" \
| grep -o '"upgrade_membership_nonce":"[a-f0-9]*"' | head -1 | cut -d'"' -f4)
# Buy the paid plan while declaring the payment method as free.
curl -s -b jar -X POST \
"$TARGET/wp-admin/admin-ajax.php?action=user_registration_membership_add_multiple_membership" \
-d "action=user_registration_membership_add_multiple_membership" \
-d "security=$NONCE" -d "selected_membership_id=PLAN_ID" -d "selected_pg=free"
# {"success":true,"data":{...,"message":"Membership purchased successfully.","order_id":"1"}}
# Role already granted, order still pending and unpaid.
curl -s -o /dev/null -w "after: %{http_code}\n" -b jar "$TARGET/wp-admin/users.php"
# after: 200
Repeat with -d "selected_pg=" for variant 1, and with a deactivated plan id for variant 2.
Remediation
- Extract the plan and payment-method validation currently inline in
Admin.php into one reusable validator on MembershipService, and call it from both entry points so every order-creating caller routes through one guard:
- plan must exist, be
publish and have a truthy post_content.status (active);
- a
free type plan forces payment_method = 'free';
- a non-free plan requires a non-empty payment method present in the plan's configured gateways or the globally active gateways, with
free accepted only when a 100% coupon genuinely zeroes a one-time plan (is_full_discount_free_order()).
- Fail closed on
defer_role: drop the ! empty( ... ) && prefix at MembershipService.php:136, so a missing or empty payment method defers the role instead of granting it.
Acceptance criteria
selected_pg=free and selected_pg= on a paid plan both return an error and grant no role.
- A deactivated plan id returns an error.
- Free purchase, paid purchase (bank / PayPal / Stripe), 100%-coupon purchase and multiple-membership purchase all still complete, and a paid plan still grants its role only once the subscription is active.
Issue B - Any visitor can read another account's details from the membership thank you page
Auth required: none. Impact: Low - depends on the site owner having put a user smart tag in the thank you block header.
Summary
modules/membership/includes/Templates/thank-you-page.php:51-65 takes a username from the query string, resolves the matching account with get_user_by( 'login', ... ), and feeds that account's ID and user_email into user_registration_process_smart_tags. Nothing checks that the visitor owns the account they named, and nothing checks that a registration or purchase happened.
An anonymous visitor naming an account gets back whichever of that account's details the page is configured to show: email address, display name, profile fields, role, plan and order information including the transaction id.
The shipped default header carries no user smart tag, so a stock configuration leaks nothing. The same template is reached through the shortcode (ThankYouShortcode::render_template()) and the Gutenberg block (UR_Block_Thank_You), which passes the header through unchanged - both are affected. The Divi module passes no header and is unaffected.
Second handle
SubscriptionService::get_membership_plan_details() (:330-339) prefers the order matching a submitted transaction_id over the named member's own order, with no check that the order belongs to $data['member_id']. So one request can pair one account's identity with another account's payment record. A fix targeting only the username would miss this.
Steps to reproduce
With the thank you block header configured as, for example, A receipt has been sent to {{email}} ({{display_name}}).
# No cookies, no account.
curl -s "$TARGET/thankyou/?username=admin"
# A receipt has been sent to admin@example.com (Super Admin).
# Control: same page with no username parameter resolves nothing.
curl -s "$TARGET/thankyou/"
# A receipt has been sent to ().
Remediation
- Resolve the subject account in
thank-you-page.php from the authenticated session (wp_get_current_user()), never from $_GET['username']. Both the shortcode and the block route through this template, so one change covers both.
- In
get_membership_plan_details(), accept the transaction-id order only when its user_id matches the member being rendered, so a submitted transaction id cannot pull another account's order.
Note the behaviour change: on a site where registration does not auto-log-in the member, the thank you page can be reached while logged out, and user smart tags will no longer resolve there. Failing closed is correct - a single-use token issued at purchase would preserve that case and can follow separately if it turns out to matter.
Acceptance criteria
?username=<other account> while logged out or logged in as a different user resolves no user smart tags.
?transaction_id=<another member's transaction> does not surface that member's order or plan details.
- A member landing on the thank you page after their own purchase still sees their own email, name, plan and transaction id.
Release
Both fold into 5.2.8, alongside #1423. The reporter has offered to check a build before release, as with the previous round.
Reported by: Karthik Ramakrishnan (via WPScan / Erwan Le Rousseau, Automattic). Affects: User Registration & Membership 5.2.7 and earlier. Verified against code on
develop.Two issues from the same report thread as #1423. Both reproduced by the reporter on 5.2.7 and confirmed against the code here.
Issue A - Subscriber can be granted any role through the membership purchase handler
Auth required: Subscriber. Impact: High - privilege escalation to any role a plan is mapped to, including Administrator, with no payment.
Summary
wp_ajax_user_registration_membership_add_multiple_membership(modules/membership/includes/AJAX.php:2066) verifies a nonce and nothing else. It does not validate:selected_pgis a payment method the plan accepts,selected_membership_idis an active plan,The nonce is not a barrier:
urm_upgrade_membershipis localised to every front-end visitor of the plan listing (modules/membership/includes/Frontend/Frontend.php:200).The submitted payment method then decides whether the plan's WordPress role is deferred.
modules/membership/includes/Admin/Services/MembershipService.php:136MembersService::update_user_meta()(:241) grants the role immediately whendefer_roleis falsy. Soselected_pg=freeon a paid plan grants the plan's role in the same request, while the order row is writtenpendingand unpaid.The registration path (
modules/membership/includes/Admin.php:537-587) already validates plan and payment method against the plan's own config (added for UR-4386), and the upgrade path derivesdefer_rolewithout the! empty()prefix so it fails closed (SubscriptionService.php:636). The purchase handler is the one that does neither.Two variants a narrow fix would miss
selected_pg=empty clears the same flag, because! empty( ... ) &&short-circuits before the'free'comparison. Matching only the literal stringfreeleaves this open.selected_membership_idis never checked againstpost_content.status, so a deactivated plan (stillpublish, soMembershipRepository::get_single_membership_by_ID()returns it) can be purchased by id and its role granted.defer_roledid not exist before 5.2.7 - those releases grant the role for any payment method. The reporter reproduced that on 5.2.6 with an ordinary bank transfer, so the deferral is a partial guard over older behaviour rather than the origin. The handler first appears in 5.0.Steps to reproduce
Site with the Membership module enabled, a paid plan whose Membership Role is above Subscriber, and a page carrying the plan listing shortcode.
Repeat with
-d "selected_pg="for variant 1, and with a deactivated plan id for variant 2.Remediation
Admin.phpinto one reusable validator onMembershipService, and call it from both entry points so every order-creating caller routes through one guard:publishand have a truthypost_content.status(active);freetype plan forcespayment_method = 'free';freeaccepted only when a 100% coupon genuinely zeroes a one-time plan (is_full_discount_free_order()).defer_role: drop the! empty( ... ) &&prefix atMembershipService.php:136, so a missing or empty payment method defers the role instead of granting it.Acceptance criteria
selected_pg=freeandselected_pg=on a paid plan both return an error and grant no role.Issue B - Any visitor can read another account's details from the membership thank you page
Auth required: none. Impact: Low - depends on the site owner having put a user smart tag in the thank you block header.
Summary
modules/membership/includes/Templates/thank-you-page.php:51-65takes a username from the query string, resolves the matching account withget_user_by( 'login', ... ), and feeds that account'sIDanduser_emailintouser_registration_process_smart_tags. Nothing checks that the visitor owns the account they named, and nothing checks that a registration or purchase happened.An anonymous visitor naming an account gets back whichever of that account's details the page is configured to show: email address, display name, profile fields, role, plan and order information including the transaction id.
The shipped default header carries no user smart tag, so a stock configuration leaks nothing. The same template is reached through the shortcode (
ThankYouShortcode::render_template()) and the Gutenberg block (UR_Block_Thank_You), which passes the header through unchanged - both are affected. The Divi module passes no header and is unaffected.Second handle
SubscriptionService::get_membership_plan_details()(:330-339) prefers the order matching a submittedtransaction_idover the named member's own order, with no check that the order belongs to$data['member_id']. So one request can pair one account's identity with another account's payment record. A fix targeting only the username would miss this.Steps to reproduce
With the thank you block header configured as, for example,
A receipt has been sent to {{email}} ({{display_name}}).Remediation
thank-you-page.phpfrom the authenticated session (wp_get_current_user()), never from$_GET['username']. Both the shortcode and the block route through this template, so one change covers both.get_membership_plan_details(), accept the transaction-id order only when itsuser_idmatches the member being rendered, so a submitted transaction id cannot pull another account's order.Note the behaviour change: on a site where registration does not auto-log-in the member, the thank you page can be reached while logged out, and user smart tags will no longer resolve there. Failing closed is correct - a single-use token issued at purchase would preserve that case and can follow separately if it turns out to matter.
Acceptance criteria
?username=<other account>while logged out or logged in as a different user resolves no user smart tags.?transaction_id=<another member's transaction>does not surface that member's order or plan details.Release
Both fold into 5.2.8, alongside #1423. The reporter has offered to check a build before release, as with the previous round.