From be4cf149c14e548d26dd4fcd0473a593e1604362 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:38:25 +0100 Subject: [PATCH 1/3] REST API: Limit the sites endpoint to the main site and tighten the response. Following the review on PR 13055, the sites endpoint now behaves like the network admin and is only usable from the network's main site: - Create, update, delete and single-site reads return a 403 `rest_cannot_{create,edit,delete,view}_not_on_main_site` error from any other site. - Listing your own sites (`user=me` or your own ID) and a member reading their site stay available from any site, but only in the view context. - Users listing their own sites cannot filter or order by fields outside the view context (`rest_forbidden_param`), so hidden values cannot be inferred. - The status flags, dates, `lang_id`, `post_count` and the site meta are now edit-context only. - Add a read-only `admin_url` field and build `home` and `siteurl` with get_home_url() and get_site_url(), so their filters apply. - Replace the write-only `title` property with a create-only, writable `blogname`, and move `user_id` out of the schema into the create route arguments. A POST to an existing site no longer runs the create path. - Skip the site queries when the user filter matches no sites, while still firing `rest_site_query`. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 160 +++- .../tests/rest-api/wpRestSiteMetaFields.php | 71 +- .../tests/rest-api/wpRestSitesController.php | 724 +++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 4 +- 4 files changed, 892 insertions(+), 67 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php index 3f7e35dfe82e3..67ee9d13a8a75 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -52,6 +52,13 @@ public function __construct() { */ public function register_routes() { + // The administrator reaches wp_initialize_site(), which only runs on creation. + $create_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ); + $create_args['user_id'] = array( + 'description' => __( 'User ID of the site administrator, set when the site is created.' ), + 'type' => 'integer', + ); + register_rest_route( $this->namespace, '/' . $this->rest_base, @@ -66,16 +73,16 @@ public function register_routes() { 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + 'args' => $create_args, ), 'allow_batch' => $this->allow_batch, 'schema' => array( $this, 'get_public_item_schema' ), ) ); - // Title and administrator reach wp_initialize_site(), which only runs on creation. + // The site title reaches wp_initialize_site(), which only runs on creation. $update_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ); - unset( $update_args['title'], $update_args['user_id'] ); + unset( $update_args['blogname'] ); register_rest_route( $this->namespace, @@ -133,18 +140,54 @@ public function get_items_permissions_check( $request ) { return $multisite_support; } - if ( $this->check_edit_permission() ) { + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + + $can_edit = $this->check_edit_permission(); + + // A user may list their own sites from any site, but only in the view context. + if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { + if ( ! $can_edit && $this->has_edit_only_filter( $request ) ) { + return new WP_Error( 'rest_forbidden_param', __( 'Sorry, you are not allowed to filter or order sites by these parameters.' ), array( 'status' => 403 ) ); + } + return true; } - // Without that capability a user may still ask for their own sites. - if ( $this->is_own_user_filter( $request ) ) { + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_view_not_on_main_site', __( 'Sorry, sites can only be viewed from the main site.' ), array( 'status' => 403 ) ); + } + + if ( $can_edit ) { return true; } return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } + /** + * Checks whether the request filters or orders by fields outside the view context. + * + * Users listing only their own sites do not see these fields, so filtering + * or ordering by them would give their values away. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool Whether the request uses a filter that needs the edit context. + */ + protected function has_edit_only_filter( $request ) { + $params = array( 'archived', 'mature', 'spam', 'deleted', 'lang_id', 'lang_id_exclude', 'before', 'after' ); + + foreach ( $params as $param ) { + // The language filters default to an empty list, which does not filter. + if ( isset( $request[ $param ] ) && array() !== $request[ $param ] ) { + return true; + } + } + + return in_array( $request['orderby'], array( 'registered', 'last_updated' ), true ); + } + /** * Checks whether the request is limited to the sites of the current user. * @@ -297,8 +340,19 @@ public function get_items( $request ) { $prepared_args['fields'] = 'ids'; } - $query = new WP_Site_Query(); - $query_result = $query->query( $prepared_args ); + // A user filter that matched no sites leaves an impossible ID, so there is nothing to query. + $has_no_sites = isset( $prepared_args['site__in'] ) && array( 0 ) === $prepared_args['site__in']; + + if ( $has_no_sites ) { + $query_result = array(); + $total_sites = 0; + $max_pages = 0; + } else { + $query = new WP_Site_Query(); + $query_result = $query->query( $prepared_args ); + $total_sites = $query->found_sites; + $max_pages = $query->max_num_pages; + } $sites = array(); @@ -309,10 +363,7 @@ public function get_items( $request ) { } } - $total_sites = $query->found_sites; - $max_pages = $query->max_num_pages; - - if ( $total_sites < 1 ) { + if ( ! $has_no_sites && $total_sites < 1 ) { // Out-of-bounds, run the query again without LIMIT for total count. unset( $prepared_args['number'], $prepared_args['offset'] ); @@ -409,7 +460,12 @@ public function get_item_permissions_check( $request ) { // Check capabilities before looking up the site, so unauthorized users can't probe which site IDs exist. $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $is_member = in_array( $context, array( 'view', 'embed' ), true ) && is_user_member_of_blog( get_current_user_id(), (int) $request['id'] ); + $is_member = 'view' === $context && is_user_member_of_blog( get_current_user_id(), (int) $request['id'] ); + + // Members may read their own sites from any site, everything else needs the main site. + if ( ! $is_member && ! is_main_site() ) { + return new WP_Error( 'rest_cannot_view_not_on_main_site', __( 'Sorry, sites can only be viewed from the main site.' ), array( 'status' => 403 ) ); + } if ( ! $is_member && ! $this->check_edit_permission() ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -487,6 +543,10 @@ public function create_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_create_not_on_main_site', __( 'Sorry, sites can only be created from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! current_user_can( 'create_sites' ) ) { return new WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to create sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -607,6 +667,10 @@ public function update_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_edit_not_on_main_site', __( 'Sorry, sites can only be edited from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! $this->check_edit_permission() ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -706,6 +770,10 @@ public function delete_item_permissions_check( $request ) { return $multisite_support; } + if ( ! is_main_site() ) { + return new WP_Error( 'rest_cannot_delete_not_on_main_site', __( 'Sorry, sites can only be deleted from the main site.' ), array( 'status' => 403 ) ); + } + if ( ! $this->check_delete_permission( (int) $request['id'] ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -815,6 +883,11 @@ public function prepare_item_for_response( $site, $request ) { return apply_filters( 'rest_prepare_site', new WP_REST_Response( array() ), $site, $request ); } + // Without a context every field would be prepared, including the ones that switch sites. + if ( empty( $request['context'] ) ) { + $request['context'] = 'view'; + } + $data = array(); $fields = $this->get_fields_for_response( $request ); @@ -874,17 +947,21 @@ public function prepare_item_for_response( $site, $request ) { $data['lang_id'] = (int) $site->lang_id; } - // These four are not columns of the sites table. Reading one switches to the site. + // These five are not columns of the sites table. Reading one switches to the site. if ( rest_is_field_included( 'blogname', $fields ) ) { $data['blogname'] = $site->blogname; } if ( rest_is_field_included( 'siteurl', $fields ) ) { - $data['siteurl'] = $site->siteurl; + $data['siteurl'] = get_site_url( (int) $site->blog_id ); } if ( rest_is_field_included( 'home', $fields ) ) { - $data['home'] = $site->home; + $data['home'] = get_home_url( (int) $site->blog_id ); + } + + if ( rest_is_field_included( 'admin_url', $fields ) ) { + $data['admin_url'] = get_admin_url( (int) $site->blog_id ); } if ( rest_is_field_included( 'post_count', $fields ) ) { @@ -998,10 +1075,12 @@ protected function prepare_item_for_database( $request ) { /* * Title, administrator and the initial options reach wp_initialize_site() * through wp_insert_site(), so they only apply while the site is created. + * A POST to an existing site is an update, so the ID tells them apart. */ - if ( WP_REST_Server::CREATABLE === $request->get_method() ) { - if ( isset( $request['title'] ) ) { - $prepared_site['title'] = $request['title']; + if ( empty( $request['id'] ) ) { + if ( isset( $request['blogname'] ) ) { + // wp_initialize_site() unslashes the title before storing it. + $prepared_site['title'] = wp_slash( $request['blogname'] ); } if ( isset( $request['user_id'] ) ) { @@ -1291,28 +1370,28 @@ public function get_item_schema() { 'description' => __( 'When the site was registered, in the site\'s timezone.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'registered_gmt' => array( 'description' => __( 'When the site was registered, as GMT.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'last_updated' => array( 'description' => __( 'When the site was last updated, in the site\'s timezone.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'last_updated_gmt' => array( 'description' => __( 'When the site was last updated, as GMT.' ), 'type' => 'string', 'format' => 'date-time', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), 'public' => array( @@ -1322,40 +1401,39 @@ public function get_item_schema() { 'default' => true, ), 'archived' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is archived. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'mature' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is mature. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'spam' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is spam. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'deleted' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'Whether the site is deleted. Default false.' ), 'type' => 'boolean', 'default' => false, ), 'lang_id' => array( - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'edit' ), 'description' => __( 'The site\'s language ID. Currently unused. Default 0.' ), 'type' => 'integer', 'default' => 0, ), 'blogname' => array( - 'description' => __( 'Site name, stored in the blogname option.' ), + 'description' => __( 'Site title, stored in the blogname option. Can only be set when the site is created.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), - 'readonly' => true, ), 'siteurl' => array( 'description' => __( 'Site address, stored in the siteurl option.' ), @@ -1371,28 +1449,28 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + 'admin_url' => array( + 'description' => __( 'Admin dashboard address of the site.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'post_count' => array( 'description' => __( 'Number of posts on the site.' ), 'type' => 'integer', - 'context' => array( 'view', 'edit' ), + 'context' => array( 'edit' ), 'readonly' => true, ), - 'title' => array( - 'description' => __( 'Site title, set when the site is created. Default is the word "Site" followed by the site ID.' ), - 'type' => 'string', - 'context' => array(), - ), - 'user_id' => array( - 'description' => __( 'User ID of the site administrator, set when the site is created.' ), - 'type' => 'integer', - 'context' => array(), - ), ), ); // Not all sites support site meta, do not register if the site does not support it. if ( is_site_meta_supported() ) { $schema['properties']['meta'] = $this->meta->get_field_schema(); + + // Registered site meta may hold sensitive data, so it is not shown to site members. + $schema['properties']['meta']['context'] = array( 'edit' ); } $this->schema = $schema; diff --git a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php index 601acddea1168..da0232e91fdd8 100644 --- a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php +++ b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php @@ -185,7 +185,9 @@ protected function reload_rest_server() { public function test_get_value() { update_site_meta( self::$site_id, 'test_single', 'testvalue' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -205,7 +207,9 @@ public function test_get_value() { public function test_get_multi_value() { add_site_meta( self::$site_id, 'test_multi', 'value1' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -233,7 +237,9 @@ public function test_get_multi_value() { public function test_get_unregistered() { update_site_meta( self::$site_id, 'test_unregistered', 'value1' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -250,7 +256,9 @@ public function test_get_unregistered() { public function test_get_registered_no_api_access() { update_site_meta( self::$site_id, 'test_no_rest', 'for_the_wicked' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -267,7 +275,9 @@ public function test_get_registered_no_api_access() { public function test_get_registered_api_disabled() { update_site_meta( self::$site_id, 'test_rest_disabled', 'sleepless_nights' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -316,7 +326,9 @@ public function test_get_value_types() { update_site_meta( self::$site_id, 'test_number', '42' ); update_site_meta( self::$site_id, 'test_bool', 1 ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -343,7 +355,9 @@ public function test_get_value_types() { public function test_get_value_custom_name() { update_site_meta( self::$site_id, 'test_custom_name', 'janet' ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -384,6 +398,7 @@ public function test_set_value() { ); $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); $request->set_body_params( $data ); + $request->set_query_params( array( 'context' => 'edit' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -458,10 +473,52 @@ public function test_set_duplicate_single_value() { ); $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); $request->set_body_params( $data ); + $request->set_query_params( array( 'context' => 'edit' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); $this->assertSame( 'test_value', get_site_meta( self::$site_id, 'test_single', true ) ); } + + /** + * Registered site meta may hold sensitive data, so site members reading + * their own site in the view context do not get it. + * + * @ticket 40365 + * @covers WP_REST_Sites_Controller::prepare_item_for_response + */ + public function test_site_member_does_not_see_meta_in_view_context() { + update_site_meta( self::$site_id, 'test_single', 'testvalue' ); + + $member_id = self::factory()->user->create(); + add_user_to_blog( self::$site_id, $member_id, 'subscriber' ); + wp_set_current_user( $member_id ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'meta', $response->get_data() ); + } + + /** + * The meta is only part of the edit context. + * + * @ticket 40365 + * @covers WP_REST_Sites_Controller::get_item_schema + */ + public function test_meta_is_only_in_the_edit_context() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $properties = $response->get_data()['schema']['properties']; + + $this->assertSame( array( 'edit' ), $properties['meta']['context'] ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'meta', $response->get_data(), 'Even a super admin does not get meta in the view context.' ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index a7bac5f3a57cf..e69d7d1c63790 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -328,14 +328,14 @@ public function test_get_item_view_context_allowed_for_site_member() { } /** - * A member of the site (even without `manage_sites`) can also view it - * in the `embed` context, same as the default `view` context. + * A member of the site without `manage_sites` can only use the view + * context, so the `embed` context is forbidden. * * @ticket 40365 * @covers ::get_item_permissions_check * @group ms-required */ - public function test_get_item_embed_context_allowed_for_site_member() { + public function test_get_item_embed_context_forbidden_for_site_member() { $blog_id = self::factory()->blog->create(); $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); add_user_to_blog( $blog_id, $user_id, 'subscriber' ); @@ -347,7 +347,7 @@ public function test_get_item_embed_context_allowed_for_site_member() { $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); } /** @@ -571,6 +571,7 @@ public function test_update_item_allows_a_site_to_keep_its_own_domain_and_path() $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/keep/' ); $request->set_param( 'mature', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -751,6 +752,7 @@ public function test_update_item() { $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); $request->set_param( 'path', '/incididunt/' ); $request->set_param( 'mature', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -1052,10 +1054,11 @@ public function test_prepare_item() { $this->assertEquals( $site->path, $data['path'] ); $this->assertEquals( mysql_to_rfc3339( $site->registered ), $data['registered_gmt'] ); $this->assertEquals( $site->blogname, $data['blogname'] ); - $this->assertEquals( $site->home, $data['home'] ); - $this->assertEquals( $site->siteurl, $data['siteurl'] ); + $this->assertSame( get_home_url( $blog_id ), $data['home'] ); + $this->assertSame( get_site_url( $blog_id ), $data['siteurl'] ); $this->assertIsBool( $data['public'] ); $this->assertIsInt( $data['post_count'] ); + $this->assertSame( get_admin_url( $blog_id ), $data['admin_url'] ); } /** @@ -1086,9 +1089,8 @@ public function test_get_item_schema() { 'blogname', 'siteurl', 'home', + 'admin_url', 'post_count', - 'title', - 'user_id', 'meta', ); @@ -1101,13 +1103,34 @@ public function test_get_item_schema() { $this->assertTrue( $properties['id']['readonly'] ); $this->assertTrue( $properties['network']['readonly'] ); $this->assertTrue( $properties['registered']['readonly'] ); - $this->assertTrue( $properties['blogname']['readonly'] ); + $this->assertArrayNotHasKey( 'readonly', $properties['blogname'], 'The site title is set when the site is created.' ); $this->assertEquals( 'boolean', $properties['public']['type'] ); $this->assertEquals( 'string', $properties['domain']['type'] ); - // Write-only, so they carry no context. - $this->assertSame( array(), $properties['title']['context'] ); - $this->assertSame( array(), $properties['user_id']['context'] ); + $this->assertTrue( $properties['admin_url']['readonly'] ); + $this->assertSame( 'uri', $properties['admin_url']['format'] ); + $this->assertSame( array( 'view', 'edit', 'embed' ), $properties['admin_url']['context'] ); + + $edit_only = array( + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'post_count', + ); + + if ( is_site_meta_supported() ) { + $edit_only[] = 'meta'; + } + + foreach ( $edit_only as $property ) { + $this->assertSame( array( 'edit' ), $properties[ $property ]['context'], "$property should only be in the edit context." ); + } } /** @@ -1123,7 +1146,9 @@ public function test_get_item_uses_the_schema_types() { $blog_id = self::factory()->blog->create( array( 'path' => '/tempora/' ) ); $site = get_site( $blog_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); @@ -1266,12 +1291,13 @@ public function test_create_item_sets_the_title_and_the_administrator() { $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/voluptas/' ); - $request->set_param( 'title', 'Voluptas' ); + $request->set_param( 'blogname', 'Voluptas' ); $request->set_param( 'user_id', $user_id ); $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Voluptas', $response->get_data()['blogname'] ); $blog_id = $response->get_data()['id']; @@ -1327,7 +1353,7 @@ public function test_update_item_does_not_accept_the_creation_fields() { } } - $this->assertArrayNotHasKey( 'title', $args ); + $this->assertArrayNotHasKey( 'blogname', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'network', $args ); $this->assertArrayHasKey( 'domain', $args ); @@ -1463,7 +1489,7 @@ public function test_get_items_does_not_switch_blogs_for_table_columns() { $site = $response->get_data()[0]; - foreach ( array( 'blogname', 'siteurl', 'home', 'post_count', 'meta' ) as $field ) { + foreach ( array( 'blogname', 'siteurl', 'home', 'admin_url', 'post_count', 'meta' ) as $field ) { $this->assertArrayNotHasKey( $field, $site ); } @@ -1627,6 +1653,7 @@ public function test_update_item_keeps_fields_that_were_not_sent() { $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); $request->set_param( 'archived', 1 ); + $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); @@ -1892,7 +1919,9 @@ public function test_get_item_exposes_site_meta() { $blog_id = self::factory()->blog->create(); update_site_meta( $blog_id, 'rest_test_site_meta', 'from blogmeta' ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -2452,4 +2481,665 @@ public function test_site_in_network_filter_can_block_the_current_network() { $this->assertErrorResponse( $error_code, $response, 403 ); } } + + /** + * Data provider for the permission checks made away from the main site. + * + * @return array[] + */ + public function data_permission_checks_off_the_main_site() { + return array( + 'list' => array( 'GET', false, 'get_items_permissions_check', 'rest_cannot_view_not_on_main_site' ), + 'read' => array( 'GET', true, 'get_item_permissions_check', 'rest_cannot_view_not_on_main_site' ), + 'create' => array( 'POST', false, 'create_item_permissions_check', 'rest_cannot_create_not_on_main_site' ), + 'update' => array( 'PUT', true, 'update_item_permissions_check', 'rest_cannot_edit_not_on_main_site' ), + 'delete' => array( 'DELETE', true, 'delete_item_permissions_check', 'rest_cannot_delete_not_on_main_site' ), + ); + } + + /** + * Even a super admin can only manage sites from the main site. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::get_item_permissions_check + * @covers ::create_item_permissions_check + * @covers ::update_item_permissions_check + * @covers ::delete_item_permissions_check + * @group ms-required + * @dataProvider data_permission_checks_off_the_main_site + * + * @param string $method HTTP method. + * @param bool $single Whether the request is for a single site. + * @param string $permission_check Permission check method name. + * @param string $error_code Expected error code. + */ + public function test_permission_checks_require_the_main_site( $method, $single, $permission_check, $error_code ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/off-main/' ) ); + $target_id = self::factory()->blog->create( array( 'path' => '/off-main-target/' ) ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( $method, $single ? '/wp/v2/sites/' . $target_id : '/wp/v2/sites' ); + if ( $single ) { + $request->set_param( 'id', $target_id ); + } + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->$permission_check( $request ); + restore_current_blog(); + + $this->assertWPError( $result ); + $this->assertSame( $error_code, $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + + // The same request passes on the main site. + $this->assertTrue( $this->endpoint->$permission_check( $request ) ); + } + + /** + * A site cannot be created through a request served by a subsite. + * + * @ticket 40365 + * @covers ::create_item_permissions_check + * @group ms-required + */ + public function test_create_item_from_a_subsite_is_forbidden() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/creator/' ) ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/from-a-subsite/' ); + + switch_to_blog( $subsite_id ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertErrorResponse( 'rest_cannot_create_not_on_main_site', $response, 403 ); + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/from-a-subsite/' ) ); + } + + /** + * Data provider for listing sites away from the main site. + * + * @return array[] + */ + public function data_get_items_off_the_main_site() { + return array( + 'own sites as me' => array( 'me', 'view', true ), + 'own sites by ID' => array( 'own', 'view', true ), + 'own sites in edit context' => array( 'me', 'edit', 'rest_cannot_view_not_on_main_site' ), + 'own sites in embed' => array( 'me', 'embed', 'rest_cannot_view_not_on_main_site' ), + 'every site' => array( '', 'view', 'rest_cannot_view_not_on_main_site' ), + 'another user' => array( 'other', 'view', 'rest_cannot_view_not_on_main_site' ), + ); + } + + /** + * Only a user listing their own sites in the view context is let through away + * from the main site, even a super admin needs the main site for everything else. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + * @dataProvider data_get_items_off_the_main_site + * + * @param string $user The user filter: 'me', 'own', 'other' or empty. + * @param string $context Request context. + * @param true|string $expected True when allowed, otherwise the error code. + */ + public function test_get_items_permissions_check_off_the_main_site( $user, $context, $expected ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/listing/' ) ); + $other_id = self::factory()->user->create(); + wp_set_current_user( self::$superadmin_id ); + + $users = array( + 'me' => 'me', + 'own' => (string) self::$superadmin_id, + 'other' => (string) $other_id, + '' => '', + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $users[ $user ] ); + $request->set_param( 'context', $context ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->get_items_permissions_check( $request ); + restore_current_blog(); + + if ( true === $expected ) { + $this->assertTrue( $result ); + } else { + $this->assertWPError( $result ); + $this->assertSame( $expected, $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + } + } + + /** + * A member lists their own sites through a request served by a subsite. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_me_filter_from_a_subsite() { + $blog_ids = self::factory()->blog->create_many( 2 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + switch_to_blog( $blog_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( array_merge( array( 1 ), $blog_ids ), wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * On the main site, the own-user filter only covers the view context, any + * other context needs the capability to manage sites. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_me_filter_needs_the_capability_outside_the_view_context() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + foreach ( array( 'edit', 'embed' ) as $context ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'context', $context ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Away from the main site, only a member reading in the view context is let + * through, whether or not the site exists. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_permissions_check_off_the_main_site() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/reader/' ) ); + $target_id = self::factory()->blog->create( array( 'path' => '/read-target/' ) ); + $member_id = self::factory()->user->create(); + $outsider = self::factory()->user->create(); + add_user_to_blog( $target_id, $member_id, 'subscriber' ); + + $check = function ( $user_id, $site_id, $context ) use ( $subsite_id ) { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $site_id ); + $request->set_param( 'id', $site_id ); + $request->set_param( 'context', $context ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->get_item_permissions_check( $request ); + restore_current_blog(); + + return $result; + }; + + $this->assertTrue( $check( $member_id, $target_id, 'view' ), 'A member may read the site in the view context.' ); + + $denied = array( + 'member in edit context' => array( $member_id, $target_id, 'edit' ), + 'member in embed context' => array( $member_id, $target_id, 'embed' ), + 'non-member' => array( $outsider, $target_id, 'view' ), + 'super admin' => array( self::$superadmin_id, $target_id, 'view' ), + 'logged out' => array( 0, $target_id, 'view' ), + 'member, unknown site' => array( $member_id, 999999, 'view' ), + 'outsider, unknown site' => array( $outsider, 999999, 'view' ), + ); + + foreach ( $denied as $label => $args ) { + $result = $check( ...$args ); + + $this->assertWPError( $result, $label ); + $this->assertSame( 'rest_cannot_view_not_on_main_site', $result->get_error_code(), $label ); + $this->assertSame( 403, $result->get_error_data()['status'], $label ); + } + } + + /** + * The fields that are not in the view context and the meta are left out for + * site members and super admins alike, and are part of the edit context. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_edit_only_fields_are_left_out_of_the_view_context() { + $blog_id = self::factory()->blog->create( array( 'path' => '/contexts/' ) ); + $member_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $member_id, 'subscriber' ); + + $edit_only = array( + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'post_count', + 'meta', + ); + + foreach ( array( $member_id, self::$superadmin_id ) as $user_id ) { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( $blog_id, $data['id'] ); + $this->assertSame( get_admin_url( $blog_id ), $data['admin_url'] ); + + foreach ( $edit_only as $field ) { + $this->assertArrayNotHasKey( $field, $data, "$field should not be in the view context." ); + } + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $data = rest_get_server()->dispatch( $request )->get_data(); + + if ( ! is_site_meta_supported() ) { + $edit_only = array_diff( $edit_only, array( 'meta' ) ); + } + + foreach ( $edit_only as $field ) { + $this->assertArrayHasKey( $field, $data, "$field should be in the edit context." ); + } + } + + /** + * Preparing a site without a context does not prepare the edit-only fields. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_prepare_item_without_a_context_does_not_switch_sites() { + $blog_id = self::factory()->blog->create( array( 'path' => '/no-context/' ) ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( '_fields', 'id,post_count' ); + + add_action( 'switch_blog', $counter ); + $data = $this->endpoint->prepare_item_for_response( get_site( $blog_id ), $request )->get_data(); + remove_action( 'switch_blog', $counter ); + + $this->assertSame( 0, $switches ); + $this->assertSame( array( 'id' => $blog_id ), $data ); + } + + /** + * The site title is set from the blogname and keeps its backslashes. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_create_item_keeps_backslashes_in_the_blogname() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/backslash/' ); + $request->set_param( 'blogname', 'C:\\Sites\\Example' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + $this->assertSame( 'C:\\Sites\\Example', get_blog_option( $response->get_data()['id'], 'blogname' ) ); + } + + /** + * Without a blogname the site gets the default title. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_create_item_without_a_blogname_uses_the_default_title() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/untitled/' ); + + $response = rest_get_server()->dispatch( $request ); + $blog_id = $response->get_data()['id']; + + $this->assertEquals( 201, $response->get_status() ); + $this->assertSame( sprintf( 'Site %d', $blog_id ), get_blog_option( $blog_id, 'blogname' ) ); + } + + /** + * Creating a site accepts the site title and the administrator. + * + * @ticket 40365 + * @covers ::register_routes + */ + public function test_create_item_accepts_the_creation_fields() { + $routes = rest_get_server()->get_routes(); + $args = array(); + + foreach ( $routes['/wp/v2/sites'] as $handler ) { + if ( ! empty( $handler['methods']['POST'] ) ) { + $args = $handler['args']; + } + } + + $this->assertArrayHasKey( 'blogname', $args ); + $this->assertArrayHasKey( 'user_id', $args ); + $this->assertSame( 'integer', $args['user_id']['type'] ); + $this->assertArrayNotHasKey( 'title', $args ); + } + + /** + * A POST to an existing site is an update, so the creation fields are ignored. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + */ + public function test_update_item_ignores_the_creation_fields() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( + array( + 'path' => '/kept-title/', + 'title' => 'Kept title', + ) + ); + + foreach ( array( 'POST', 'PUT' ) as $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'blogname', 'Changed title' ); + $request->set_param( 'user_id', 99999 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $method ); + $this->assertSame( 'Kept title', get_blog_option( $blog_id, 'blogname' ), $method ); + } + } + + /** + * Data provider for the collection parameters that only apply with the + * capability to manage sites. + * + * @return array[] + */ + public function data_get_items_hidden_field_params() { + return array( + 'archived' => array( 'archived', true ), + 'mature' => array( 'mature', true ), + 'spam' => array( 'spam', true ), + 'deleted' => array( 'deleted', true ), + 'lang_id' => array( 'lang_id', array( 7 ) ), + 'lang_id_exclude' => array( 'lang_id_exclude', array( 0 ) ), + 'before' => array( 'before', '2000-01-01T00:00:00' ), + 'after' => array( 'after', '2999-01-01T00:00:00' ), + 'spam false' => array( 'spam', false ), + 'orderby date' => array( 'orderby', 'registered' ), + 'orderby update' => array( 'orderby', 'last_updated' ), + ); + } + + /** + * Users listing their own sites cannot filter or order by the fields they + * cannot see, otherwise the results would give the hidden values away. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::has_edit_only_filter + * @group ms-required + * @dataProvider data_get_items_hidden_field_params + * + * @param string $param Collection parameter. + * @param mixed $value Value that would narrow or reorder the results. + */ + public function test_get_items_me_filter_rejects_hidden_field_params( $param, $value ) { + $blog_id = self::factory()->blog->create( array( 'path' => '/hidden-params/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( $param, $value ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_param', $response, 403 ); + } + + /** + * A super admin listing their own sites can still use every filter. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + * @dataProvider data_get_items_hidden_field_params + * + * @param string $param Collection parameter. + * @param mixed $value Value that would narrow or reorder the results. + */ + public function test_get_items_me_filter_allows_hidden_field_params_for_super_admins( $param, $value ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( $param, $value ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * The empty default of the language filters and the default order are not + * treated as filters, so a plain own-sites request is still allowed. + * + * @ticket 40365 + * @covers ::has_edit_only_filter + * @group ms-required + */ + public function test_get_items_me_filter_allows_the_default_params() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $request->set_param( 'lang_id', array() ); + $request->set_param( 'orderby', 'domain' ); + $request->set_param( 'public', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for user filters that match no sites. + * + * @return array[] + */ + public function data_get_items_user_filter_without_sites() { + return array( + 'GET, no memberships' => array( 'GET', false ), + 'HEAD, no memberships' => array( 'HEAD', false ), + 'GET, include outside the set' => array( 'GET', true ), + ); + } + + /** + * When the user filter matches no sites there is nothing to query, but the + * query filter still runs and the response looks like any empty collection. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + * @dataProvider data_get_items_user_filter_without_sites + * + * @param string $method HTTP method. + * @param bool $with_include Whether to include a site the user is not a member of. + */ + public function test_get_items_user_filter_without_sites_skips_the_query( $method, $with_include ) { + $blog_id = self::factory()->blog->create( array( 'path' => '/not-mine/' ) ); + $user_id = self::factory()->user->create(); + + // Users are members of the main site, which the filter then excludes. + remove_user_from_blog( $user_id, 1 ); + + if ( $with_include ) { + add_user_to_blog( 1, $user_id, 'subscriber' ); + } + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( $method, '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + if ( $with_include ) { + $request->set_param( 'include', array( $blog_id ) ); + } + + $filtered = 0; + $queries = 0; + + $count_query = static function () use ( &$queries ) { + ++$queries; + }; + $count_filter = static function ( $args ) use ( &$filtered, $count_query ) { + ++$filtered; + + // Looking up the user's sites queries too, so only what follows the filter counts. + add_action( 'pre_get_sites', $count_query ); + + return $args; + }; + + add_filter( 'rest_site_query', $count_filter ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'rest_site_query', $count_filter ); + remove_action( 'pre_get_sites', $count_query ); + + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertSame( '0', $headers['X-WP-Total'] ); + $this->assertSame( '0', $headers['X-WP-TotalPages'] ); + $this->assertSame( 1, $filtered, 'The query filter should still run.' ); + $this->assertSame( 0, $queries, 'No site query should run.' ); + } + + /** + * A plugin can still widen an empty user filter through the query filter. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_user_filter_without_sites_can_be_widened() { + $blog_id = self::factory()->blog->create( array( 'path' => '/widened/' ) ); + $user_id = self::factory()->user->create(); + remove_user_from_blog( $user_id, 1 ); + wp_set_current_user( $user_id ); + + $widen = static function ( $args ) use ( $blog_id ) { + $args['site__in'] = array( $blog_id ); + return $args; + }; + + add_filter( 'rest_site_query', $widen ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'rest_site_query', $widen ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array( $blog_id ), wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * The addresses go through the URL functions, so their filters apply. + * + * @ticket 40365 + * @covers ::prepare_item_for_response + * @group ms-required + */ + public function test_prepare_item_uses_the_filtered_site_addresses() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/filtered-urls/' ) ); + + $home_filter = static function ( $url, $path, $scheme, $filtered_blog_id ) use ( $blog_id ) { + return $blog_id === $filtered_blog_id ? 'https://home.example.org/' : $url; + }; + $site_filter = static function ( $url, $path, $scheme, $filtered_blog_id ) use ( $blog_id ) { + return $blog_id === $filtered_blog_id ? 'https://site.example.org/' : $url; + }; + + add_filter( 'home_url', $home_filter, 10, 4 ); + add_filter( 'site_url', $site_filter, 10, 4 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'home_url', $home_filter, 10 ); + remove_filter( 'site_url', $site_filter, 10 ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 'https://home.example.org/', $data['home'] ); + $this->assertSame( 'https://site.example.org/', $data['siteurl'] ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index b22ff76829e1c..e34663864c60a 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11117,8 +11117,8 @@ mockedApiResponse.Schema = { "type": "integer", "required": false }, - "title": { - "description": "Site title, set when the site is created. Default is the word \"Site\" followed by the site ID.", + "blogname": { + "description": "Site title, stored in the blogname option. Can only be set when the site is created.", "type": "string", "required": false }, From 423bf96ea99d2553dbc1a76c1d56c68c3ef4e723 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:44:23 +0100 Subject: [PATCH 2/3] REST API: Use a single validated user parameter for sites. The collection filter `user` and the create-only `user_id` argument described the same relationship under two names. Both are now `user`, sharing one schema that accepts a positive user ID or "me", and get_user_id_from_param() resolves "me" to the current user. - Invalid values such as `xyz`, `0`, `-1` or an empty string are now rejected with `rest_invalid_param` before the permission check. - Numeric strings are sanitized to integers. - An unknown administrator on creation returns `rest_user_invalid_id`. - Listing the sites of another user requires the capability to manage sites (`rest_forbidden_user`). Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 72 +++-- .../tests/rest-api/wpRestSitesController.php | 296 +++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 38 ++- 3 files changed, 369 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php index 67ee9d13a8a75..5fbfc3847e35f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -53,11 +53,8 @@ public function __construct() { public function register_routes() { // The administrator reaches wp_initialize_site(), which only runs on creation. - $create_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ); - $create_args['user_id'] = array( - 'description' => __( 'User ID of the site administrator, set when the site is created.' ), - 'type' => 'integer', - ); + $create_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ); + $create_args['user'] = $this->get_user_param_schema( __( 'The site administrator, set when the site is created. Accepts a user ID or "me".' ) ); register_rest_route( $this->namespace, @@ -143,6 +140,12 @@ public function get_items_permissions_check( $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $can_edit = $this->check_edit_permission(); + $user = $request['user']; + + // Only users who can manage sites may list the sites of another user. + if ( ! empty( $user ) && 'me' !== $user && get_current_user_id() !== $this->get_user_id_from_param( $user ) && ! $can_edit ) { + return new WP_Error( 'rest_forbidden_user', __( 'Sorry, you are not allowed to list the sites of other users.' ), array( 'status' => rest_authorization_required_code() ) ); + } // A user may list their own sites from any site, but only in the view context. if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { @@ -203,11 +206,45 @@ protected function is_own_user_filter( $request ) { return false; } - if ( 'me' === $user ) { - return is_user_logged_in(); - } + return is_user_logged_in() && get_current_user_id() === $this->get_user_id_from_param( $user ); + } - return is_user_logged_in() && get_current_user_id() === (int) $user; + /** + * Retrieves the schema of a user parameter, which accepts a user ID or "me". + * + * @since 7.2.0 + * + * @param string $description Parameter description. + * @return array Parameter schema. + */ + protected function get_user_param_schema( $description ) { + return array( + 'description' => $description, + // Without a top-level type the request skips validating and sanitizing the parameter. + 'type' => array( 'integer', 'string' ), + 'anyOf' => array( + array( + 'type' => 'integer', + 'minimum' => 1, + ), + array( + 'type' => 'string', + 'enum' => array( 'me' ), + ), + ), + ); + } + + /** + * Resolves a user parameter to a user ID, "me" being the current user. + * + * @since 7.2.0 + * + * @param int|string $user A user ID or "me". + * @return int User ID, 0 for "me" when logged out. + */ + protected function get_user_id_from_param( $user ) { + return 'me' === $user ? get_current_user_id() : (int) $user; } /** @@ -274,7 +311,7 @@ public function get_items( $request ) { $user = $request['user']; if ( ! empty( $user ) ) { - $user_id = ( 'me' === $user ) ? get_current_user_id() : (int) $user; + $user_id = $this->get_user_id_from_param( $user ); $site_ids = $this->get_user_site_ids( $user_id ); if ( ! empty( $prepared_args['site__in'] ) ) { @@ -1083,12 +1120,14 @@ protected function prepare_item_for_database( $request ) { $prepared_site['title'] = wp_slash( $request['blogname'] ); } - if ( isset( $request['user_id'] ) ) { - if ( ! get_userdata( (int) $request['user_id'] ) ) { - return new WP_Error( 'rest_site_invalid_user_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + if ( isset( $request['user'] ) ) { + $user_id = $this->get_user_id_from_param( $request['user'] ); + + if ( ! get_userdata( $user_id ) ) { + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); } - $prepared_site['user_id'] = (int) $request['user_id']; + $prepared_site['user_id'] = $user_id; } } @@ -1571,10 +1610,7 @@ public function get_collection_params() { 'site__in', ), ); - $query_params['user'] = array( - 'description' => __( 'Limit result set to the sites a user is a member of. Accepts a user ID or "me".' ), - 'type' => 'string', - ); + $query_params['user'] = $this->get_user_param_schema( __( 'Limit result set to the sites a user is a member of. Accepts a user ID or "me".' ) ); $status_descriptions = array( 'public' => __( 'Limit result set to sites with a specific public status.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index e69d7d1c63790..9c43e735f6c7d 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -234,21 +234,21 @@ public function test_get_items_me_filter_forbidden_when_logged_out() { } /** - * A non-numeric `user` value casts to `0` via `(int)`, which must not be - * allowed to match a logged-out request's `get_current_user_id()` (also - * `0`) and grant the own-sites-filter bypass. + * A non-numeric `user` value is rejected by validation, so it never reaches + * the permission check, where an `(int)` cast to `0` could match a logged-out + * request's `get_current_user_id()`. * * @ticket 40365 - * @covers ::get_items_permissions_check + * @covers ::get_user_param_schema * @group ms-required */ - public function test_get_items_non_numeric_user_filter_forbidden_when_logged_out() { + public function test_get_items_rejects_a_non_numeric_user() { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'user', 'xyz' ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } /** @@ -1292,7 +1292,7 @@ public function test_create_item_sets_the_title_and_the_administrator() { $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/voluptas/' ); $request->set_param( 'blogname', 'Voluptas' ); - $request->set_param( 'user_id', $user_id ); + $request->set_param( 'user', $user_id ); $response = rest_get_server()->dispatch( $request ); @@ -1318,18 +1318,17 @@ public function test_create_item_sets_the_title_and_the_administrator() { * @covers ::create_item * @group ms-required */ - public function test_create_item_rejects_an_unknown_user_id() { + public function test_create_item_rejects_an_unknown_user() { wp_set_current_user( self::$superadmin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/quisquam/' ); - $request->set_param( 'user_id', 99999 ); + $request->set_param( 'user', 99999 ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 400, $response->get_status() ); - $this->assertEquals( 'rest_site_invalid_user_id', $response->get_data()['code'] ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 400 ); // The check runs before wp_insert_site(), so nothing was created. $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/quisquam/' ) ); @@ -1354,6 +1353,7 @@ public function test_update_item_does_not_accept_the_creation_fields() { } $this->assertArrayNotHasKey( 'blogname', $args ); + $this->assertArrayNotHasKey( 'user', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'network', $args ); $this->assertArrayHasKey( 'domain', $args ); @@ -2029,7 +2029,7 @@ public function test_get_items_filter_user_no_access() { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'user', (string) $user_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_forbidden_user', $response, 403 ); } /** @@ -2870,8 +2870,22 @@ public function test_create_item_accepts_the_creation_fields() { } $this->assertArrayHasKey( 'blogname', $args ); - $this->assertArrayHasKey( 'user_id', $args ); - $this->assertSame( 'integer', $args['user_id']['type'] ); + $this->assertArrayHasKey( 'user', $args ); + $this->assertSame( array( 'integer', 'string' ), $args['user']['type'] ); + $this->assertSame( + array( + array( + 'type' => 'integer', + 'minimum' => 1, + ), + array( + 'type' => 'string', + 'enum' => array( 'me' ), + ), + ), + $args['user']['anyOf'] + ); + $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'title', $args ); } @@ -2895,7 +2909,15 @@ public function test_update_item_ignores_the_creation_fields() { foreach ( array( 'POST', 'PUT' ) as $method ) { $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); $request->set_param( 'blogname', 'Changed title' ); - $request->set_param( 'user_id', 99999 ); + $request->set_param( 'user', 99999 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $method ); + $this->assertSame( 'Kept title', get_blog_option( $blog_id, 'blogname' ), $method ); + + // The update route does not register the parameter, so it is not validated either. + $request->set_param( 'user', 'xyz' ); $response = rest_get_server()->dispatch( $request ); @@ -3142,4 +3164,248 @@ public function test_prepare_item_uses_the_filtered_site_addresses() { $this->assertSame( 'https://home.example.org/', $data['home'] ); $this->assertSame( 'https://site.example.org/', $data['siteurl'] ); } + + /** + * Data provider for user parameter values that are neither a user ID nor "me". + * + * @return array[] + */ + public function data_invalid_user_params() { + return array( + 'word' => array( 'xyz' ), + 'zero' => array( '0' ), + 'zero integer' => array( 0 ), + 'negative' => array( -1 ), + 'me with extra' => array( 'me2' ), + 'empty' => array( '' ), + 'decimal' => array( '5.5' ), + 'list' => array( array( 1 ) ), + ); + } + + /** + * The collection filter only accepts a user ID or "me". + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + * @dataProvider data_invalid_user_params + * + * @param mixed $user User parameter value. + */ + public function test_get_items_rejects_an_invalid_user( $user ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Creating a site only accepts a user ID or "me" as the administrator. + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + * @dataProvider data_invalid_user_params + * + * @param mixed $user User parameter value. + */ + public function test_create_item_rejects_an_invalid_user( $user ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/invalid-user/' ); + $request->set_param( 'user', $user ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/invalid-user/' ) ); + } + + /** + * A numeric string is sanitized to an integer, "me" is kept as it is. + * + * @ticket 40365 + * @covers ::get_user_param_schema + * @group ms-required + */ + public function test_get_items_sanitizes_the_user() { + wp_set_current_user( self::$superadmin_id ); + + $seen = array(); + $capture = static function ( $args, $request ) use ( &$seen ) { + $seen[] = $request['user']; + return $args; + }; + + add_filter( 'rest_site_query', $capture, 10, 2 ); + + foreach ( array( (string) self::$superadmin_id, 'me' ) as $user ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user ); + + $this->assertEquals( 200, rest_get_server()->dispatch( $request )->get_status() ); + } + + remove_filter( 'rest_site_query', $capture, 10 ); + + $this->assertSame( array( self::$superadmin_id, 'me' ), $seen ); + } + + /** + * "me" resolves to the current user, a numeric value to its integer. + * + * @ticket 40365 + * @covers ::get_user_id_from_param + */ + public function test_get_user_id_from_param() { + $method = $this->get_reflective_method( 'get_user_id_from_param' ); + + $this->assertSame( 0, $method->invoke( $this->endpoint, 'me' ), 'Logged out, "me" is no user.' ); + + wp_set_current_user( self::$superadmin_id ); + + $this->assertSame( self::$superadmin_id, $method->invoke( $this->endpoint, 'me' ) ); + $this->assertSame( 7, $method->invoke( $this->endpoint, '7' ) ); + $this->assertSame( 7, $method->invoke( $this->endpoint, 7 ) ); + } + + /** + * Data provider for the ways to name the administrator of a new site. + * + * @return array[] + */ + public function data_create_item_user_values() { + return array( + 'me' => array( 'me', false ), + 'integer' => array( 'id', false ), + 'numeric string' => array( 'string', false ), + 'JSON integer' => array( 'id', true ), + 'JSON string' => array( 'string', true ), + ); + } + + /** + * The administrator can be given as "me", an integer or a numeric string, + * in the query or a JSON body, and the new site then shows up in their list. + * + * @ticket 40365 + * @covers ::prepare_item_for_database + * @group ms-required + * @dataProvider data_create_item_user_values + * + * @param string $kind Which value to send: 'me', 'id' or 'string'. + * @param bool $json Whether to send the parameters as a JSON body. + */ + public function test_create_item_sets_the_user_as_administrator( $kind, $json ) { + wp_set_current_user( self::$superadmin_id ); + + $values = array( + 'me' => 'me', + 'id' => self::$superadmin_id, + 'string' => (string) self::$superadmin_id, + ); + + $params = array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/administered/', + 'user' => $values[ $kind ], + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + + if ( $json ) { + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + } else { + $request->set_body_params( $params ); + } + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $blog_id = $response->get_data()['id']; + + $this->assertTrue( is_user_member_of_blog( self::$superadmin_id, $blog_id ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $this->assertContains( $blog_id, wp_list_pluck( rest_get_server()->dispatch( $request )->get_data(), 'id' ) ); + } + + /** + * Without the capability to manage sites, a user may only filter by their + * own ID or "me", whichever site serves the request. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_another_user_needs_the_capability() { + $subsite_id = self::factory()->blog->create( array( 'path' => '/other-user/' ) ); + $user_id = self::factory()->user->create(); + $other_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $other_id ); + + $this->assertErrorResponse( 'rest_forbidden_user', rest_get_server()->dispatch( $request ), 403 ); + + switch_to_blog( $subsite_id ); + $response = rest_get_server()->dispatch( $request ); + restore_current_blog(); + + $this->assertErrorResponse( 'rest_forbidden_user', $response, 403 ); + + foreach ( array( 'me', $user_id, (string) $user_id ) as $own ) { + $request->set_param( 'user', $own ); + + $this->assertEquals( 200, rest_get_server()->dispatch( $request )->get_status(), 'A user can list their own sites.' ); + } + } + + /** + * A logged-out request cannot filter by a user ID. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_user_filter_forbidden_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', self::$superadmin_id ); + + $this->assertErrorResponse( 'rest_forbidden_user', rest_get_server()->dispatch( $request ), 401 ); + } + + /** + * A super admin can list the sites of any user. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_super_admin_can_filter_by_another_user() { + $blog_id = self::factory()->blog->create( array( 'path' => '/someone-else/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', $user_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index e34663864c60a..3e0b4c9305821 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11004,7 +11004,22 @@ mockedApiResponse.Schema = { }, "user": { "description": "Limit result set to the sites a user is a member of. Accepts a user ID or \"me\".", - "type": "string", + "type": [ + "integer", + "string" + ], + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "enum": [ + "me" + ] + } + ], "required": false }, "public": { @@ -11122,9 +11137,24 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "user_id": { - "description": "User ID of the site administrator, set when the site is created.", - "type": "integer", + "user": { + "description": "The site administrator, set when the site is created. Accepts a user ID or \"me\".", + "type": [ + "integer", + "string" + ], + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "enum": [ + "me" + ] + } + ], "required": false } } From e43b75bf0561fc12d0b2f7e1a8f3c0c7c6ea7122 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:59:57 +0100 Subject: [PATCH 3/3] REST API: Require the capability to manage sites for the edit context. - Both read permission checks reject `context=edit` up front with `rest_forbidden_context` unless the user can manage sites, before the member, main-site and site lookup checks, so an edit-context request does not reveal whether a site exists. - get_items_permissions_check() resolves the own-user filter once and reuses it for the other-user check, so a logged-out `user=me` now returns `rest_forbidden_user`. - The check against filtering or ordering by fields outside the view context now applies to every user without the capability, and still runs before the own-sites exemption. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 26 +++-- .../tests/rest-api/wpRestSitesController.php | 94 ++++++++++++++++++- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php index 5fbfc3847e35f..f8d0d30f19189 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -140,19 +140,24 @@ public function get_items_permissions_check( $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $can_edit = $this->check_edit_permission(); - $user = $request['user']; + $is_own = $this->is_own_user_filter( $request ); + + if ( 'edit' === $context && ! $can_edit ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } // Only users who can manage sites may list the sites of another user. - if ( ! empty( $user ) && 'me' !== $user && get_current_user_id() !== $this->get_user_id_from_param( $user ) && ! $can_edit ) { + if ( ! empty( $request['user'] ) && ! $is_own && ! $can_edit ) { return new WP_Error( 'rest_forbidden_user', __( 'Sorry, you are not allowed to list the sites of other users.' ), array( 'status' => rest_authorization_required_code() ) ); } - // A user may list their own sites from any site, but only in the view context. - if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { - if ( ! $can_edit && $this->has_edit_only_filter( $request ) ) { - return new WP_Error( 'rest_forbidden_param', __( 'Sorry, you are not allowed to filter or order sites by these parameters.' ), array( 'status' => 403 ) ); - } + // Fields outside the view context must not be inferable through filters or ordering either. + if ( ! $can_edit && $this->has_edit_only_filter( $request ) ) { + return new WP_Error( 'rest_forbidden_param', __( 'Sorry, you are not allowed to filter or order sites by these parameters.' ), array( 'status' => 403 ) ); + } + // A user may list their own sites from any site, but only in the view context. + if ( 'view' === $context && $is_own ) { return true; } @@ -496,7 +501,12 @@ public function get_item_permissions_check( $request ) { } // Check capabilities before looking up the site, so unauthorized users can't probe which site IDs exist. - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + + if ( 'edit' === $context && ! $this->check_edit_permission() ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + $is_member = 'view' === $context && is_user_member_of_blog( get_current_user_id(), (int) $request['id'] ); // Members may read their own sites from any site, everything else needs the main site. diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 9c43e735f6c7d..51fd4a1630507 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -230,7 +230,8 @@ public function test_get_items_me_filter_forbidden_when_logged_out() { $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + // Logged out, "me" is no one, so it is treated like another user's ID. + $this->assertErrorResponse( 'rest_forbidden_user', $response, 401 ); } /** @@ -2709,8 +2710,12 @@ public function test_get_item_permissions_check_off_the_main_site() { $this->assertTrue( $check( $member_id, $target_id, 'view' ), 'A member may read the site in the view context.' ); + $result = $check( $member_id, $target_id, 'edit' ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_forbidden_context', $result->get_error_code(), 'The edit context needs the capability before the site matters.' ); + $denied = array( - 'member in edit context' => array( $member_id, $target_id, 'edit' ), 'member in embed context' => array( $member_id, $target_id, 'embed' ), 'non-member' => array( $outsider, $target_id, 'view' ), 'super admin' => array( self::$superadmin_id, $target_id, 'view' ), @@ -3408,4 +3413,89 @@ public function test_get_items_super_admin_can_filter_by_another_user() { $this->assertEquals( 200, $response->get_status() ); $this->assertContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); } + + /** + * Data provider for the edit context checks of both read routes. + * + * @return array[] + */ + public function data_edit_context_permission_checks() { + return array( + 'collection' => array( 'get_items_permissions_check', false ), + 'single site' => array( 'get_item_permissions_check', true ), + ); + } + + /** + * The edit context needs the capability to manage sites, even for a member + * reading their own site or listing their own sites. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::get_item_permissions_check + * @group ms-required + * @dataProvider data_edit_context_permission_checks + * + * @param string $permission_check Permission check method name. + * @param bool $single Whether the request is for a single site. + */ + public function test_edit_context_needs_the_capability( $permission_check, $single ) { + $subsite_id = self::factory()->blog->create( array( 'path' => '/edit-context/' ) ); + $member_id = self::factory()->user->create(); + add_user_to_blog( $subsite_id, $member_id, 'administrator' ); + + $request = new WP_REST_Request( 'GET', $single ? '/wp/v2/sites/' . $subsite_id : '/wp/v2/sites' ); + $request->set_param( 'context', 'edit' ); + + if ( $single ) { + $request->set_param( 'id', $subsite_id ); + } else { + $request->set_param( 'user', 'me' ); + } + + $assert_forbidden = function ( $result, $status, $message ) { + $this->assertWPError( $result, $message ); + $this->assertSame( 'rest_forbidden_context', $result->get_error_code(), $message ); + $this->assertSame( $status, $result->get_error_data()['status'], $message ); + }; + + $assert_forbidden( $this->endpoint->$permission_check( $request ), 401, 'Logged out.' ); + + // Even an administrator of the site is not allowed to use the edit context. + wp_set_current_user( $member_id ); + + $assert_forbidden( $this->endpoint->$permission_check( $request ), 403, 'A member on the main site.' ); + + switch_to_blog( $subsite_id ); + $result = $this->endpoint->$permission_check( $request ); + restore_current_blog(); + + $assert_forbidden( $result, 403, 'A member on their own site.' ); + + wp_set_current_user( self::$superadmin_id ); + + $this->assertTrue( $this->endpoint->$permission_check( $request ), 'A super admin can use the edit context.' ); + } + + /** + * The edit context is checked before the site is looked up, so it does not + * tell an unknown site apart from an existing one. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_edit_context_does_not_reveal_site_existence() { + $blog_id = self::factory()->blog->create( array( 'path' => '/edit-existence/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + wp_set_current_user( $user_id ); + + foreach ( array( $blog_id, REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) as $id ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $id ); + $request->set_param( 'context', 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', rest_get_server()->dispatch( $request ), 403 ); + } + } }