Skip to content

#1408 Fix - Prevent membership privilege escalation and open redirect after login - #1422

Merged
y000yal merged 5 commits into
developfrom
fix/1408-security-membership-privilege-escalation-and-login-open-redirect
Sep 9, 2026
Merged

#1408 Fix - Prevent membership privilege escalation and open redirect after login#1422
y000yal merged 5 commits into
developfrom
fix/1408-security-membership-privilege-escalation-and-login-open-redirect

Conversation

@MILAN88888

Copy link
Copy Markdown
Contributor

All Submissions:

Changes proposed in this Pull Request:

Fixes the two vulnerabilities from the WPScan report (themegrill/user-registration-pro#1408), both reproduced live against the current code and re-verified fixed. Researchers to credit: Baikuya (Jonathan Dersch) (privilege escalation) and Sai Praneeth Koti (open redirect).

Vulnerability 1 — Author → administrator privilege escalation (CVSS 7.2). The chain was: the ur_membership CPT registered with capability_type => 'post' and no capabilities map, so any Contributor+ could author and publish a plan and, because the ur_membership meta key is not protected, write role: administrator into it through core's own edit-post/add-meta path. The self-service add_multiple_membership AJAX handler then granted that role synchronously on a selected_pg=free request, gated only by a nonce that is localized to every visitor of a membership page. Reproduced end to end: an Author reached administrator + manage_options in three requests.

The fix is layered so no single change carries the whole weight, and — importantly — so it does not break plans an administrator has legitimately configured with a privileged role (e.g. a real "staff" plan granting editor):

  • CPT capability lock (modules/membership/includes/Admin.php) — both ur_membership and ur_membership_groups now map every primitive post capability to manage_options, keeping non-admins out of post-new.php/post.php and, because add_post_meta maps through edit_post, out of the custom-fields write path too. This is the load-bearing layer.
  • Meta auth_callback (Admin.php) — the ur_membership/ur_membership_groups meta keys are registered with an auth_callback requiring manage_options, so core's add-meta path stays refused even if a site re-widens the post type through the user_registration_membership_post_type filter. The plugin's own update_post_meta() calls are unaffected.
  • Role validation at plan save (MembershipService::validate_membership_data()) — a privileged role can only be attached to a plan by a user who holds promote_users, and a non-existent role is rejected. This closes the injection at its source while leaving legitimate admin-authored plans intact.
  • Role backstop at the point of assignment (ur_membership_get_safe_role() in includes/Functions/CoreFunctions.php, applied in MembersService::update_user_meta() and maybe_grant_pending_role()) — a plan whose author cannot assign roles can never grant a privileged one, whatever its stored value; the requested role is honoured only when the plan's author holds promote_users. Every downgrade is logged. The blocklist deliberately excludes unfiltered_html and list_users so the stock editor and WooCommerce shop_manager roles remain valid plan roles.
  • Self-service purchase hardening (modules/membership/includes/AJAX.php) — add_multiple_membership and upgrade_membership now reject a plan the site does not currently offer, and add_multiple_membership gained the payment-method allowlist the registration and upgrade paths already had (extracted into one shared MembershipService method so the three cannot drift). This closes the selected_pg=free lever, which was also free access to any paid plan.

Vulnerability 2 — open redirect in the login handler (CVSS 6.1). ur_process_login() redirected to a request-controlled target without a same-host check: the failed-login branch used the raw referer unvalidated (exploitable fully unauthenticated), and the success branch called wp_validate_redirect( $redirect, $redirect ), which is a no-op because the untrusted value was its own fallback. Reproduced: a login response reflected https://evil.example/phish; after the fix it returns the home URL.

  • Both branches now use wp_safe_redirect() with a local fallback, and the validation is hoisted above the AJAX branch so it also covers the client-side navigation (window.location.href = res.data.message) that a fix on the header redirect alone would miss.
  • The failed branch guards the empty-referer case explicitly, because wp_validate_redirect( '', $fallback ) returns '', not the fallback.
  • A new allowed_redirect_hosts filter (UR_Frontend::allow_custom_redirect_host()) whitelists the host of the admin-configured External URL login redirect, and only when that setting is active, so validating the redirect does not break that documented feature.

Not changed, deliberately: the unauthenticated registration path was already hardened (the plan id is read server-side and validated against the form's offered plans), so it is left as-is. Two related items are out of scope and will be filed separately: two open redirects in pro-only code (includes/pro/functions-ur-pro.php passwordless magic-link, includes/pro/addons/sms-integration/Frontend.php) that the free→pro sync cannot carry, and a separate edit_member role-assignment gap (edit_users reaching set_role() where core requires promote_users).

Note: this diff overlaps the approved #1417 on one line (functions-ur-core.php:5408); whichever merges second resolves a trivial one-line conflict, and this version keeps #1417's allowed_redirect_hosts approach so the two are compatible. @since tags assume the next release is 5.2.8. The changelog is intentionally not touched, per CONTRIBUTING.

Closes themegrill/user-registration-pro#1408

How to test the changes in this Pull Request:

Privilege escalation (as an Author):

  1. Create and log in as an Author. Open wp-admin/post-new.php?post_type=ur_membership.
    • Before: the editor loads. After: 403.
  2. If the CPT lock is bypassed, submit an editpost to wp-admin/post.php setting metakeyinput=ur_membership and metavalue={"type":"free","role":"administrator",...}.
    • Before: the meta is written. After: the auth_callback refuses it.
  3. Read upgrade_membership_nonce from any membership page and POST action=user_registration_membership_add_multiple_membership, selected_pg=free, selected_membership_id=<a plan whose stored role is administrator>.
    • Before: the account gains administrator. After: the account gains subscriber, and a warning is logged ("Refused to grant privileged role ...").
  4. Regression: purchase a plan an administrator legitimately configured to grant administrator (or editor) — the role must still be granted. Save a plan as an administrator with role administrator (must succeed) and as a lower role (must fail with "Sorry, you are not allowed to assign that role to a membership.").
  5. Regression: selected_pg=free against a paid plan must be rejected ("Invalid payment method for this membership."); a real purchase per configured gateway and a 100%-coupon order must still work.

Open redirect (unauthenticated where noted):

  1. Failed login (wrong password) with _wp_http_referer=https://evil.example/phish → must land on My Account, not evil.example. With no referer at all → an absolute My Account URL.
  2. With AJAX login enabled, POST action=user_registration_ajax_login_submit with valid credentials and redirect=https://evil.example/phishdata.message must be the home URL, not evil.example. Also try //evil.example and https:evil.example.
  3. Regression: Login Options → enable custom redirect, After Login Redirect = External URL = https://example.com/welcome → a successful login must still reach it; switch the setting to Internal Page and confirm example.com is no longer whitelisted.

Types of changes:

  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (modification of the currently available functionality)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you successfully ran tests with your changes locally?
  • Have you updated the documentation accordingly?

PHPCS: clean on all changed lines (ruleset phpcs.xml, PHP_CodeSniffer 3.13.5, 8 files). The two WordPress.Security.NonceVerification.Missing warnings on the new $_POST reads carry a per-line phpcs:ignore — the nonce is verified via ur_membership_verify_nonce() at the top of each handler, which PHPCS cannot see. Verified end to end on a live site with the pro plugin active (the free plugin's includes/modules are the sync source for pro): the full escalation was reproduced before the fix and blocked after, each layer independently; the open redirect reflected evil.example before and the home URL after.

Changelog entry

Fix - Privilege escalation via membership role and open redirect after login.

@tg-autopilot

tg-autopilot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Build for 376e9dec is ready 🛎️

⬇️ Download user-registration-5.2.7.zip (8.1M)

Installs directly via Plugins → Add New → Upload Plugin.
Link expires in 30 days · updated Sep 8, 2026 10:27 AM +0545

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There is at least one confirmed functional bug that can cause runtime errors (auth_callback signature mismatch) and should be corrected before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR hardens the membership and login flows to address two reported vulnerabilities: (1) preventing membership plan–based role/privilege escalation and (2) preventing open redirects after login, while preserving legitimate admin-configured behavior (e.g., External URL redirects and privileged roles intentionally granted by authorized admins).

Changes:

  • Lock down membership-related CPT capabilities and protect membership/group meta keys from core’s custom-fields write path.
  • Add layered role safety validation/backstops during membership save and role assignment, and harden membership purchase/upgrade AJAX paths (plan allowlist + payment-method allowlist).
  • Fix login redirects by validating/safeguarding redirects (including AJAX login) and allowing only the admin-configured External URL host when that setting is enabled.
File summaries
File Description
modules/membership/includes/AJAX.php Rejects non-purchasable plans and invalid payment methods in membership purchase/upgrade AJAX handlers.
modules/membership/includes/Admin/Services/SubscriptionService.php Centralizes payment-method validation via shared MembershipService logic.
modules/membership/includes/Admin/Services/MembersService.php Applies role “safe” backstop during immediate and deferred (pending) role grants.
modules/membership/includes/Admin/Services/MembershipService.php Adds purchasable-plan check and shared payment-method validation (including 100%-coupon free order rules).
modules/membership/includes/Admin.php Locks CPT capabilities and registers protected post meta with auth_callback.
includes/Functions/CoreFunctions.php Introduces privileged-role detection and a “safe role” resolver with logging + filters.
includes/functions-ur-core.php Fixes open redirect by validating/safely redirecting on both success and failed login paths (incl. AJAX).
includes/frontend/class-ur-frontend.php Whitelists admin-configured External URL host via allowed_redirect_hosts when that feature is enabled.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 5407 to +5411
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 );
Comment thread modules/membership/includes/Admin.php Outdated
Comment on lines +1002 to +1004
public function can_manage_membership_meta() {
return current_user_can( 'manage_options' );
}
@MILAN88888
MILAN88888 requested a review from y000yal September 7, 2026 11:50
@MILAN88888 MILAN88888 self-assigned this Sep 7, 2026
@MILAN88888 MILAN88888 added the bug label Sep 7, 2026
@MILAN88888

Copy link
Copy Markdown
Contributor Author

Thanks — addressed both in 6e71d1e.

1. Dead code in the AJAX-login success path (functions-ur-core.php) — correct, and pre-existing. wp_send_json_success() calls wp_die(), so the following wp_send_json( $user ) and the duplicate trailing if block were both unreachable. Removed; behaviour is unchanged.

2. can_manage_membership_meta() signature — the specific claim here isn't accurate: PHP does not throw ArgumentCountError when a user function is called with extra positional arguments (only when required ones are missing), so a zero-parameter auth_callback invoked with the filter's args runs fine — this ships on a PHP 7.4 baseline, and it was verified live before the original PR (author denied writing ur_membership meta, admin allowed, no error).

That said, the underlying suggestion is a good one, so I applied it: the method now declares the documented callback parameters and returns $allowed && current_user_can( 'manage_options' ). AND-ing with $allowed means it can only ever narrow a core decision, never widen one — strictly safer, and self-documenting. Re-verified after the change: apply_filters( 'auth_post_meta_ur_membership_for_ur_membership', … ) returns false for an Author and true for an admin, matching the original behaviour.

PHPCS: clean on both changed files.

@MILAN88888

Copy link
Copy Markdown
Contributor Author

@tg-autopilot review

Addressed both review comments in 6e71d1e (dead AJAX-login code removed; meta auth_callback now declares the documented filter params and AND-s with $allowed). Requesting a re-review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new register_post_meta auth_callback has an incompatible signature and authorization logic that can cause PHP 8+ runtime errors and/or unintended always-deny behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

includes/frontend/class-ur-frontend.php:268

  • allow_custom_redirect_host() appends the external host to $hosts every time the allowed_redirect_hosts filter runs, which can introduce duplicate entries within a request and across multiple validations. Deduplicating here avoids unnecessary growth/processing and keeps the allow-list stable.
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +995 to +1012
/**
* 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( $allowed = false, $meta_key = '', $object_id = 0, $user_id = 0 ) {
return $allowed && current_user_can( 'manage_options' );
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 20e777d. Two notes on the specifics, since both claims here are testable and don't hold on this codebase:

  1. ArgumentCountError — PHP only raises this for too few required arguments, never for extra positional ones (a callback declaring fewer params than WordPress passes simply ignores the surplus). Verified: a 0-arg function called with 6 args returns normally, no error. This plugin also targets PHP 7.4 (Requires PHP: 7.4). So there was no runtime error — but I've still widened the signature to the full documented six params ($allowed, $meta_key, $object_id, $user_id, $cap, $caps) so it matches the filter exactly.

  2. "always deny"$allowed is only false here if the meta were protected. WordPress passes $allowed = ! is_protected_meta( $meta_key, 'post' ), and these keys have no leading underscore, so it is true. The = false in the signature is just the zero-arg default, which the filter never triggers. Verified live before and after this change: apply_filters( 'auth_post_meta_ur_membership_for_ur_membership', … ) returns true for an administrator and false for an Author. To remove the ambiguity entirely, the body now returns current_user_can( 'manage_options' ) directly rather than combining with $allowed.

Also deduped the host in allow_custom_redirect_host() (the suppressed note) so the allow-list can't grow within a request. PHPCS clean on both files.

@MILAN88888

Copy link
Copy Markdown
Contributor Author

@tg-autopilot review

Follow-up in 20e777d: can_manage_membership_meta() now declares WP's full 6-argument auth_callback signature and returns current_user_can( 'manage_options' ) directly; allow_custom_redirect_host() dedupes the host. Both verified (Author denied / admin allowed) and PHPCS-clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-critical changes span multiple membership and login entry points and should receive final human verification despite only minor review notes.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

modules/membership/includes/Admin/Services/MembershipService.php:128

  • is_membership_purchasable() currently loads the entire active membership list (DB query + filtering) and then linearly scans it. This method is called in AJAX handlers, so this adds avoidable overhead and duplicates other membership lookups. Consider checking the specific membership ID and then applying the same frontend-list filter to that single item.
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread modules/membership/includes/Admin.php Outdated
* @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, $cap = '', $caps = array() ) {
return current_user_can( 'manage_options' );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 66ae9c0. The callback now returns user_can( $user_id, 'manage_options' ), so it evaluates the user WordPress passes rather than the current request user. Verified: evaluating for an Author while the current user is an admin now correctly returns false (the old current_user_can() would have wrongly allowed it); the exploit path — where the acting user is the current user — still denies Authors and allows admins.

On the suppressed note about is_membership_purchasable(): keeping it as-is, deliberately. It reuses list_active_memberships() because that applies the build_membership_list_frontend filter, so a plan a site has hidden through that filter can't be purchased either — and this site does hook that filter. Checking a single membership ID and applying a list-context filter to one item wouldn't reproduce that reliably and could let a hidden plan through. At realistic plan counts (16 here) the scan cost is negligible next to that guarantee.

@MILAN88888

Copy link
Copy Markdown
Contributor Author

@tg-autopilot review

66ae9c0: meta auth_callback now checks the passed $user_id (correct for cross-user capability evaluation), verified. The is_membership_purchasable() note is intentionally kept — see the inline reply for the security rationale.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new centralized payment-method validator currently treats free plans as accepting any submitted payment method, which can drive inconsistent order/subscription behavior in purchase flows.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

modules/membership/includes/Admin/Services/MembershipService.php:147

  • is_valid_payment_method_for_membership() currently returns true for free memberships regardless of the submitted payment method. Because purchase flows branch on $data['payment_method'] (e.g., treating non-'free' as a gateway and setting initial status to pending), a forged request can force a free plan down a paid-gateway path and create inconsistent orders/subscriptions. For free plans, only accept an empty payment method or 'free'.
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@MILAN88888

Copy link
Copy Markdown
Contributor Author

@tg-autopilot review

376e9de addresses the suppressed note on is_valid_payment_method_for_membership(): a free plan now accepts only an empty or free method, so a forged gateway value can't push it onto the paid/deferred-role order path. Verified — legitimate free purchases (free/empty) still pass, stripe on a free plan now returns false, paid-plan behaviour unchanged. (The registration path already forced free for free plans; this closes the additional-purchase and upgrade paths too.)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes multiple security-critical pathways (role assignment, purchasability checks, and redirect handling) where subtle regressions can have high impact and merits final human validation.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@y000yal y000yal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

Traced this in depth given the severity:

  • CPT capability lock + meta auth_callback — correct signature/hook order, doesn't touch read/read_private_posts so plan listings still render, doesn't affect the plugin's own update_post_meta() calls (those bypass auth_callback entirely, only core's classic-editor/REST meta paths consult it).
  • ur_membership_get_safe_role() backstop in MembersService::update_user_meta() — confirmed $membership_id resolves correctly for every registration path (free, paid immediate, deferred), not just deferred ones, by tracing prepare_members_data()membership_data['membership'] is always set from $data['membership'] regardless of plan type. So a legitimate admin-authored plan with a privileged role (e.g. a real staff editor plan) won't get falsely downgraded.
  • validate_membership_data() correctly requires promote_users to save a privileged role at write time.
  • Payment-method/purchasability checks consolidated into one shared method and applied at all three self-service entry points (registration, upgrade, add_multiple_membership) — no drift between them.
  • Open redirect: validation hoisted above the AJAX branch so it also covers the client-side window.location.href navigation, empty-referer case guarded explicitly, and the External URL allowlist is scoped to only when that setting is actually active.

Checked MembersRepository::create() (the other wp_insert_user()/set_role() site) — capability-gated behind add_users, not reachable unauthenticated, so no sibling gap there. The edit_member / promote_users mismatch you flagged as separately-filed is correctly out of scope for this one.

One tracking note: includes/Functions/CoreFunctions.php, includes/frontend/class-ur-frontend.php, includes/functions-ur-core.php, and all 5 changed modules/membership files are currently byte-identical between this repo and themegrill/user-registration-pro — per the issue's own instruction that's intentional (fix free, let sync propagate), but pro stays exploitable until that sync PR actually lands and merges there, so worth someone tracking that it does.

@y000yal
y000yal merged commit 75c940d into develop Sep 9, 2026
3 of 4 checks passed
@y000yal
y000yal deleted the fix/1408-security-membership-privilege-escalation-and-login-open-redirect branch September 9, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants