Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 15 additions & 49 deletions src/wp-includes/abilities/class-wp-abilities-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -460,14 +460,16 @@ private function execute_content_query( $input = array() ) {
);
}

$author = $this->get_author_by_slug( $where['author_slug'], $post_type_object );
if ( ! $author ) {
if ( ! is_string( $where['author_slug'] ) ) {
return $this->invalid_filter_error(
'where[author_slug]',
/* translators: %s: Parameter. */
sprintf( __( 'The %s filter must be the slug of an existing user.' ), 'author_slug' )
sprintf( __( 'The %s filter must be a string.' ), 'author_slug' )
);
}

// A slug that names no user stays false, see below.
$author = get_user_by( 'slug', $where['author_slug'] );
}

/*
Expand Down Expand Up @@ -561,14 +563,22 @@ private function execute_content_query( $input = array() ) {
$query_args['post_name__in'] = $slugs;
}

if ( null !== $author ) {
if ( $author ) {
$query_args['author'] = $author->ID;
}

if ( null !== $parent ) {
$query_args['post_parent'] = $parent;
}

/*
* An author_slug that names no user matches no post, like IDs and slugs that name no
* post, rather than failing the query.
*/
if ( false === $author ) {
return $this->prepare_output( array(), 0, 0 );
}

$query = new WP_Query( $query_args );
$total = $this->get_query_total( $query, $query_args, $page );

Expand Down Expand Up @@ -745,50 +755,6 @@ private function is_field_requested( string $path, array $fields ): bool {
return rest_is_field_included( $path, $fields );
}

/**
* Looks up the user an author slug names.
*
* The slug is the user's nicename, which the REST API users endpoint returns as `slug`. It is
* looked up as given, like that endpoint's `slug` filter, so the database collation decides
* whether a variant in another case matches. A user the current user may not see is reported
* like a missing one. The current user can see themselves, any user of the site when they can
* list users, and authors with posts in a publicly viewable post type. A user who can edit
* others' posts of the post type can also see any user of the site, since they may make any of
* them the author, so for them the lookup does tell whether such an account exists.
*
* On multisite the lookup searches the whole network, so posts by authors who are not
* members of the site, such as super admins, can still be filtered. Those users are only
* visible as the current user or as authors with posts in a publicly viewable post type, so
* the lookup does not tell whether an account exists elsewhere on the network.
*
* @since 7.2.0
*
* @param mixed $slug The author slug.
* @param WP_Post_Type $post_type_object The post type the author is looked up for.
* @return WP_User|null The user, or null when the slug does not name a visible user.
*/
private function get_author_by_slug( $slug, WP_Post_Type $post_type_object ): ?WP_User {
$user = is_string( $slug ) ? get_user_by( 'slug', $slug ) : false;
if ( ! $user ) {
return null;
}

if ( get_current_user_id() === $user->ID ) {
return $user;
}

// The capabilities only reveal users of the site, not of the whole network.
if ( is_user_member_of_blog( $user->ID )
&& ( current_user_can( 'list_users' ) || current_user_can( $post_type_object->cap->edit_others_posts ) )
) {
return $user;
}

$public_post_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) );

return ! empty( $public_post_types ) && count_user_posts( $user->ID, $public_post_types ) > 0 ? $user : null;
}

/**
* Returns a post type exposed through the Abilities API.
*
Expand Down Expand Up @@ -1198,7 +1164,7 @@ private function get_content_query_input_schema( array $post_types, array $statu
'author_slug' => array(
'type' => 'string',
'minLength' => 1,
'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. Only supported for post types that support authors." ),
'description' => __( "Filter by the author's user slug (nicename), as the REST API users endpoint returns it in `slug`. A slug that names no user matches no post. Only supported for post types that support authors." ),
),
'parent' => array(
'type' => 'integer',
Expand Down
140 changes: 48 additions & 92 deletions tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -5036,53 +5036,53 @@ public function test_malformed_date_is_null( string $field, string $column ): vo
}

/**
* Returns author slugs that do not name a user.
* An author_slug filter that is not a string is rejected rather than silently dropped,
* which would widen the query to every author's posts.
*
* @return array<string, array{0: mixed}> The author slug filter value.
* @ticket 66268
*/
public function data_author_slugs_that_name_no_user(): array {
return array(
'unknown slug' => array( 'no-such-user' ),
'not a string' => array( 5 ),
public function test_execute_callback_rejects_an_author_slug_that_is_not_a_string(): void {
$this->login_as( 'administrator' );
$execute = $this->get_ability_callbacks()['execute_callback'];

$result = $execute(
array(
'where' => array(
'type' => 'post',
'author_slug' => 5,
),
)
);

$this->assertWPError( $result, 'An author_slug that is not a string must not silently widen the query to all authors.' );
$this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An author_slug that is not a string should fail as an invalid filter.' );
$this->assertSame(
array( 'where[author_slug]' => $result->get_error_message() ),
$result->get_error_data()['params'],
'The error data should map the malformed author_slug filter to the error message.'
);
}

/**
* An author_slug filter that does not name a user is rejected rather than silently
* dropped, which would widen the query to every author's posts.
* An author_slug filter that names no user matches no post, rather than being dropped,
* which would widen the query to every author's posts.
*
* @ticket 66268
* @dataProvider data_author_slugs_that_name_no_user
*
* @param mixed $author_slug The author slug filter value.
*/
public function test_execute_callback_rejects_an_author_slug_that_names_no_user( $author_slug ): void {
wp_update_user(
array(
'ID' => self::$user_ids['author'],
'user_nicename' => 'author-slug',
)
);

public function test_author_slug_filter_that_names_no_user_matches_no_post(): void {
$this->login_as( 'administrator' );
$execute = $this->get_ability_callbacks()['execute_callback'];

$result = $execute(
array(
'where' => array(
'type' => 'post',
'author_slug' => $author_slug,
'author_slug' => 'no-such-user',
),
)
);

$this->assertWPError( $result, 'An author_slug that names no user must not silently widen the query to all authors.' );
$this->assertSame( 'content_invalid_filter', $result->get_error_code(), 'An unhonorable author_slug filter should fail closed as an invalid filter.' );
$this->assertSame(
array( 'where[author_slug]' => $result->get_error_message() ),
$result->get_error_data()['params'],
'The error data should map the unmatched author_slug filter to the error message.'
);
$this->assertSame( self::EMPTY_RESULT, $result, 'An author_slug that names no user should match no post.' );
}

/**
Expand Down Expand Up @@ -5664,14 +5664,13 @@ public function test_execute_callback_defaults_a_pagination_that_is_not_an_objec
}

/**
* An author_slug filter names only users the current user may see: a user without
* published posts is reported like a missing one to a subscriber, but not to an editor,
* who can edit their posts.
* An author_slug filter matches only the posts the current user may read, so a user
* without such posts gives the same empty result as a slug that names no user.
*
* @ticket 66268
* @since 7.2.0
*/
public function test_author_slug_filter_only_names_users_the_current_user_may_see(): void {
public function test_author_slug_filter_matches_no_post_for_a_user_without_readable_posts(): void {
$published_id = self::factory()->post->create( array( 'post_author' => self::$user_ids['author'] ) );
$draft_id = self::factory()->post->create(
array(
Expand All @@ -5681,90 +5680,48 @@ public function test_author_slug_filter_only_names_users_the_current_user_may_se
);

$execute = $this->get_ability_callbacks()['execute_callback'];
$query = static function ( array $statuses, int $user_id ) use ( $execute ) {
$query = static function ( array $statuses, string $author_slug ) use ( $execute ) {
return $execute(
array(
'where' => array(
'type' => 'post',
'status' => $statuses,
'author_slug' => get_userdata( $user_id )->user_nicename,
'author_slug' => $author_slug,
),
'fields' => array( 'id' ),
)
);
};

$author_slug = get_userdata( self::$user_ids['author'] )->user_nicename;
$author_secondary_slug = get_userdata( self::$user_ids['author_secondary'] )->user_nicename;
$subscriber_slug = get_userdata( self::$user_ids['subscriber'] )->user_nicename;

$this->login_as( 'subscriber' );
$public = $query( array( 'publish' ), self::$user_ids['author'] );
$public = $query( array( 'publish' ), $author_slug );
$this->assertIsArray( $public, 'A subscriber should filter by an author with published posts.' );
$this->assertSame( array( $published_id ), wp_list_pluck( $public['items'], 'id' ), 'The filter should return that author\'s posts.' );

$hidden = $query( array( 'publish' ), self::$user_ids['author_secondary'] );
$this->assertWPError( $hidden, 'A subscriber should not learn that an author without published posts exists.' );
$this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'A hidden author should be reported like a missing one.' );
$this->assertSame(
array( 'where[author_slug]' => $hidden->get_error_message() ),
$hidden->get_error_data()['params'],
'The error data should map the hidden author_slug filter to the error message.'
);
$this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $author_secondary_slug ), 'A subscriber should get the empty result for an author without published posts.' );
$this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), 'no-such-user' ), 'A subscriber should get the empty result for a slug that names no user.' );

$this->login_as( 'editor' );
$drafts = $query( array( 'draft' ), self::$user_ids['author_secondary'] );
$this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), $subscriber_slug ), 'An editor should get the empty result for a user without posts.' );
$this->assertSame( self::EMPTY_RESULT, $query( array( 'publish' ), 'no-such-user' ), 'An editor should get the empty result for a slug that names no user.' );

$drafts = $query( array( 'draft' ), $author_secondary_slug );
$this->assertIsArray( $drafts, 'An editor should filter by an author without published posts.' );
$this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s drafts.' );
}

/**
* For a post type with its own capabilities, an author_slug filter names a user without
* published posts to the users who can edit others' posts of that post type, not to those
* who can edit others' posts.
*
* @ticket 66268
* @since 7.2.0
*/
public function test_author_slug_filter_follows_the_capabilities_of_the_queried_post_type(): void {
$this->register_post_type_with_own_capabilities();

$draft_id = self::factory()->post->create(
array(
'post_type' => 'book',
'post_author' => self::$user_ids['author_secondary'],
'post_status' => 'draft',
)
);

$execute = $this->get_ability_callbacks()['execute_callback'];
$input = array(
'where' => array(
'type' => 'book',
'status' => array( 'draft' ),
'author_slug' => get_userdata( self::$user_ids['author_secondary'] )->user_nicename,
),
'fields' => array( 'id' ),
);

$this->login_as( 'editor' );
$hidden = $execute( $input );

$this->assertWPError( $hidden, 'An editor, who can edit others\' posts but not others\' books, should not learn that an author without published posts exists.' );
$this->assertSame( 'content_invalid_filter', $hidden->get_error_code(), 'An author hidden from the editor should be reported like a missing one.' );

wp_set_current_user( $this->create_subscriber_with_capabilities( array( 'edit_books', 'edit_others_books' ) ) );
$drafts = $execute( $input );

$this->assertIsArray( $drafts, 'A user who can edit others\' books should filter by an author without published posts.' );
$this->assertSame( array( $draft_id ), wp_list_pluck( $drafts['items'], 'id' ), 'The filter should return that author\'s draft books.' );
}

/**
* On multisite, an author_slug filter does not tell an editor of one site whether a user
* of another site exists.
* On multisite, an author_slug filter that names a user of another site matches no post,
* like a slug that names no user.
*
* @ticket 66268
* @group ms-required
* @since 7.2.0
*/
public function test_author_slug_filter_hides_users_of_other_sites(): void {
public function test_author_slug_filter_matches_no_post_for_a_user_of_another_site(): void {
$other_user_id = self::factory()->user->create( array( 'user_nicename' => 'other-site-user' ) );
$site_id = self::factory()->blog->create();

Expand All @@ -5790,9 +5747,8 @@ public function test_author_slug_filter_hides_users_of_other_sites(): void {
restore_current_blog();

$this->assertFalse( $is_member, 'Precondition: the other user should not be a member of the site.' );
$this->assertWPError( $missing, 'Precondition: a slug that names no user should be rejected.' );
$this->assertWPError( $other, 'An editor should not learn that a user of another site exists.' );
$this->assertSame( $missing->get_error_code(), $other->get_error_code(), 'A user of another site should be reported like a missing one.' );
$this->assertSame( self::EMPTY_RESULT, $missing, 'Precondition: a slug that names no user should match no post.' );
$this->assertSame( self::EMPTY_RESULT, $other, 'A user of another site should match no post of the site.' );
}

/**
Expand Down
Loading