Skip to content

Commit 8f92b4b

Browse files
obenlandclaude
andcommitted
Fix application password revocation on multisite for non-members
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>
1 parent b51590f commit 8f92b4b

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-0
lines changed

settings/rest-api.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
add_action( 'rest_api_init', __NAMESPACE__ . '\register_rest_routes' );
1212
add_action( 'rest_api_init', __NAMESPACE__ . '\register_user_fields' );
13+
add_filter( 'rest_pre_dispatch', __NAMESPACE__ . '\allow_application_password_management', 10, 3 );
1314
add_filter( 'rest_pre_insert_user', __NAMESPACE__ . '\require_email_confirmation', 10, 2 );
1415
add_filter( 'bp_before_profile_edit_content', __NAMESPACE__ . '\process_email_change_confirmation' );
1516
add_filter( 'admin_page_access_denied', __NAMESPACE__ . '\redirect_wpadmin_profile' );
@@ -466,6 +467,69 @@ function register_user_fields(): void {
466467
);
467468
}
468469

470+
/**
471+
* Allow users to manage their own application passwords on multisite, even if
472+
* they are not a member of the current blog.
473+
*
474+
* Core's WP_REST_Application_Passwords_Controller::get_user() checks
475+
* is_user_member_of_blog() and returns a 404 for non-members. On WordPress.org,
476+
* most users aren't members of every blog (e.g., profiles.wordpress.org), so
477+
* application password operations like revoke fail.
478+
*
479+
* This works by filtering `get_user_metadata` to return a minimal capabilities
480+
* array for the current user's blog capabilities key, which makes
481+
* is_user_member_of_blog() return true.
482+
*
483+
* @param mixed $result Response to replace the requested version with. Can be anything
484+
* a normal endpoint can return, or null to not hijack the request.
485+
* @param \WP_REST_Server $server Server instance.
486+
* @param \WP_REST_Request $request Request used to generate the response.
487+
* @return mixed Unmodified $result.
488+
*/
489+
function allow_application_password_management( $result, $server, $request ) {
490+
if ( ! is_multisite() ) {
491+
return $result;
492+
}
493+
494+
if ( ! preg_match( '#^/wp/v2/users/\d+/application-passwords#', $request->get_route() ) ) {
495+
return $result;
496+
}
497+
498+
$current_user_id = get_current_user_id();
499+
if ( ! $current_user_id ) {
500+
return $result;
501+
}
502+
503+
add_filter(
504+
'get_user_metadata',
505+
function ( $check, $user_id, $meta_key ) use ( $current_user_id ) {
506+
global $wpdb;
507+
508+
if ( $user_id !== $current_user_id ) {
509+
return $check;
510+
}
511+
512+
$blog_id = get_current_blog_id();
513+
$capabilities_key = $wpdb->base_prefix;
514+
if ( 1 !== $blog_id ) {
515+
$capabilities_key .= $blog_id . '_';
516+
}
517+
$capabilities_key .= 'capabilities';
518+
519+
if ( $meta_key !== $capabilities_key ) {
520+
return $check;
521+
}
522+
523+
// Return a nested array: get_metadata() unwraps one level when $single is true.
524+
return array( array( 'subscriber' => true ) );
525+
},
526+
10,
527+
3
528+
);
529+
530+
return $result;
531+
}
532+
469533
/**
470534
* Implement the "Require email confirmation" functionality for the REST API.
471535
*

tests/settings/test-application-passwords.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,59 @@ public function test_application_passwords_field_not_in_view_context() : void {
155155
$this->assertArrayNotHasKey( 'application_passwords', $actual );
156156
}
157157

158+
/**
159+
* Verify that a user who is not a member of the current blog can still
160+
* revoke their own application password via the REST API.
161+
*
162+
* @covers WordPressdotorg\Two_Factor\allow_application_password_management
163+
*/
164+
public function test_non_member_can_revoke_application_password() : void {
165+
wp_set_current_user( self::$regular_user->ID, self::$regular_user->user_login );
166+
167+
list( , $item ) = WP_Application_Passwords::create_new_application_password(
168+
self::$regular_user->ID,
169+
array( 'name' => 'Revoke Test' )
170+
);
171+
172+
// Remove the user from the current blog to simulate profiles.wordpress.org.
173+
$remove_user_callback = function ( $check, $user_id, $meta_key ) {
174+
global $wpdb;
175+
176+
if ( $user_id !== self::$regular_user->ID ) {
177+
return $check;
178+
}
179+
180+
$blog_id = get_current_blog_id();
181+
$capabilities_key = $wpdb->base_prefix;
182+
if ( 1 !== $blog_id ) {
183+
$capabilities_key .= $blog_id . '_';
184+
}
185+
$capabilities_key .= 'capabilities';
186+
187+
if ( $meta_key !== $capabilities_key ) {
188+
return $check;
189+
}
190+
191+
// Return false wrapped in an array: get_metadata() unwraps one level,
192+
// yielding false, which fails is_array() in is_user_member_of_blog().
193+
return array( false );
194+
};
195+
196+
add_filter( 'get_user_metadata', $remove_user_callback, 9, 3 );
197+
198+
$this->assertFalse(
199+
is_user_member_of_blog( self::$regular_user->ID ),
200+
'Precondition: user should not be a member of the blog.'
201+
);
202+
203+
$request = new WP_REST_Request( 'DELETE', '/wp/v2/users/' . self::$regular_user->ID . '/application-passwords/' . $item['uuid'] );
204+
$response = rest_do_request( $request );
205+
206+
remove_filter( 'get_user_metadata', $remove_user_callback, 9 );
207+
208+
$this->assertSame( 200, $response->get_status(), 'Non-member should be able to revoke their own application password.' );
209+
}
210+
158211
/**
159212
* @covers WordPressdotorg\Two_Factor\register_user_fields
160213
*/

0 commit comments

Comments
 (0)