Fix application password revocation on multisite for non-members#348
Fix application password revocation on multisite for non-members#348
Conversation
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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_dispatchhook that filtersget_user_metadataduring application-password REST routes to makeis_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.
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>
There was a problem hiding this comment.
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.
| 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; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
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.
| 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; | |
| } | |
| /** |
|
I wish 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 :) |
|
I experimented with running this in the capabilities file of the /**
* 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 ); |
Summary
WP_REST_Application_Passwords_Controller::get_user()checksis_user_member_of_blog()on multisite and returns a 404 for non-membersget_user_metadataduring application password REST requests to makeis_user_member_of_blog()return true for the current userProps @westonruter for reporting, @dd32 for identifying the root cause.
Test plan
npm testpasses (26 tests including newtest_non_member_can_revoke_application_password)npm run lint:jspasses with 0 errors🤖 Generated with Claude Code