Skip to content

Fix application password revocation on multisite for non-members#348

Open
obenland wants to merge 5 commits intotrunkfrom
fix/application-password-multisite-member-check
Open

Fix application password revocation on multisite for non-members#348
obenland wants to merge 5 commits intotrunkfrom
fix/application-password-multisite-member-check

Conversation

@obenland
Copy link
Copy Markdown
Member

@obenland obenland commented Apr 7, 2026

Summary

  • Core's WP_REST_Application_Passwords_Controller::get_user() checks is_user_member_of_blog() on multisite and returns a 404 for non-members
  • On profiles.wordpress.org, most users aren't members of that blog, so application password operations (revoke, revoke all) fail with "Invalid user ID"
  • Filters get_user_metadata during application password REST requests to make is_user_member_of_blog() return true for the current user

Props @westonruter for reporting, @dd32 for identifying the root cause.

Test plan

  • Verify npm test passes (26 tests including new test_non_member_can_revoke_application_password)
  • Verify npm run lint:js passes with 0 errors
  • On a multisite, confirm a non-member user can revoke their own application passwords via the REST API

🤖 Generated with Claude Code

Core's REST API application passwords controller checks
is_user_member_of_blog() and rejects non-members with a 404. On
WordPress.org, most users aren't members of every blog (e.g.,
profiles.wordpress.org), so operations like revoke fail.

Filter `get_user_metadata` during application password REST requests to
return a minimal capabilities array for the current user, making
is_user_member_of_blog() return true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 7, 2026 13:53
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes failures when non-member users on a multisite (e.g. profiles.wordpress.org) attempt to revoke their own application passwords via the core REST API.

Changes:

  • Add a rest_pre_dispatch hook that filters get_user_metadata during application-password REST routes to make is_user_member_of_blog() pass for the current user.
  • Add a multisite-focused unit test covering revocation for a non-member user.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
settings/rest-api.php Adds REST pre-dispatch hook to allow application-password management for non-members on multisite.
tests/settings/test-application-passwords.php Adds a test ensuring non-members can revoke their own application passwords via REST.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

obenland and others added 3 commits April 7, 2026 09:04
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove the get_user_metadata filter via rest_post_dispatch so it
  doesn't persist beyond the request.
- Return an empty capabilities array instead of subscriber role —
  is_array() passes without granting any real capabilities.
- Assert the application password is actually deleted in the test.
- Fix closure spacing per WordPress coding standards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Format rest_post_dispatch closure per WordPress-Core standards
- Use Yoda condition in treat_as_member_of_blog()
- Drop redundant default priority from remove_filter()
- Add blank line before return in closure
- Fix function keyword spacing in test closure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +498 to +511
add_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog', 10, 3 );
add_filter(
'rest_post_dispatch',
function ( $response ) {
remove_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog' );

return $response;
}
);

return $result;
}

/**
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

allow_application_password_management() adds an anonymous rest_post_dispatch filter each matching request, but that callback is never removed. In long-running PHP processes (unit tests, WP-CLI, persistent workers), repeated REST calls will accumulate these closures and they’ll run on every subsequent REST response. Consider using a named rest_post_dispatch callback (or a self-removing closure stored in a variable) that removes itself after running, and/or guard with has_filter() to avoid re-adding it when already present.

Suggested change
add_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog', 10, 3 );
add_filter(
'rest_post_dispatch',
function ( $response ) {
remove_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog' );
return $response;
}
);
return $result;
}
/**
if ( ! has_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog' ) ) {
add_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog', 10, 3 );
}
if ( ! has_filter( 'rest_post_dispatch', __NAMESPACE__ . '\cleanup_application_password_management_filters' ) ) {
add_filter( 'rest_post_dispatch', __NAMESPACE__ . '\cleanup_application_password_management_filters', 10, 3 );
}
return $result;
}
/**
* Remove temporary filters added to allow application password management.
*
* @param mixed $response Response object or data.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request used to generate the response.
* @return mixed Unmodified $response.
*/
function cleanup_application_password_management_filters( $response, $server, $request ) {
remove_filter( 'get_user_metadata', __NAMESPACE__ . '\treat_as_member_of_blog', 10 );
remove_filter( 'rest_post_dispatch', __NAMESPACE__ . '\cleanup_application_password_management_filters', 10 );
return $response;
}
/**

Copilot uses AI. Check for mistakes.
@dd32
Copy link
Copy Markdown
Member

dd32 commented Apr 8, 2026

I wish is_user_member_of_blog had a filter :) This isn't the first time this has bitten us

This feels wrong to me, but I think it's the best option.

I wonder if we should apply this as a general filter on profiles.wordpress.org rather than a specific two-factor filter though.. but this'll do for now I guess :)

@obenland
Copy link
Copy Markdown
Member Author

obenland commented Apr 8, 2026

I experimented with running this in the capabilities file of the wporg-profiles plugin. We'd need to use a global to avoid an infinite recursion with get_current_user_id() but works to fix it more generally:

/**
 * Ensure the current user appears as a member of this site.
 *
 * Most WordPress.org users are not members of profiles.wordpress.org, but
 * they need to be treated as such for REST API operations on their own data.
 *
 * @param mixed  $value   The value to return, or null to continue lookup.
 * @param int    $user_id The user ID.
 * @param string $key     The meta key.
 * @return mixed
 */
function ensure_current_user_membership( $value, $user_id, $key ) {
	global $wpdb, $current_user;

	if ( ! $current_user instanceof WP_User || $current_user->ID !== $user_id ) {
		return $value;
	}

	if ( $wpdb->get_blog_prefix() . 'capabilities' !== $key ) {
		return $value;
	}

	remove_filter( 'get_user_metadata', __NAMESPACE__ . '\ensure_current_user_membership' );
	$existing = get_user_meta( $user_id, $key, true );
	add_filter( 'get_user_metadata', __NAMESPACE__ . '\ensure_current_user_membership', 10, 3 );

	if ( $existing ) {
		return $value;
	}

	// An empty capabilities array satisfies is_user_member_of_blog()'s is_array() check without granting any capabilities.
	return [ [] ];
}
add_filter( 'get_user_metadata', __NAMESPACE__ . '\ensure_current_user_membership', 10, 3 );

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants