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 09903dc2b96cb..6cac590e6dd36 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 @@ -970,6 +970,11 @@ protected function prepare_item_for_database( $request ) { $prepared_site['domain'] = $request['domain']; } + $domain_check = $this->check_domain_is_available( $prepared_site, $request ); + if ( is_wp_error( $domain_check ) ) { + return $domain_check; + } + /** * Filters a site after it is prepared for the database. * @@ -983,6 +988,209 @@ protected function prepare_item_for_database( $request ) { return apply_filters( 'rest_preprocess_site', $prepared_site, $request ); } + /** + * Validates that the `domain` parameter is a valid hostname or IP address. + * + * @since 7.2.0 + * + * @param mixed $value Value of the `domain` parameter. + * @param WP_REST_Request $request The current request. + * @param string $param The parameter name. + * @return true|WP_Error True if the value is valid, otherwise a WP_Error object. + */ + public function validate_domain( $value, $request, $param ) { + $valid = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( $this->is_valid_domain( $value ) ) { + return true; + } + + return new WP_Error( + 'rest_invalid_param', + __( 'domain must be a valid hostname or IP address (IPv4 or IPv6), optionally followed by a port. Bracketed IPv6 addresses are not supported.' ), + array( + 'status' => 400, + 'param' => $param, + ) + ); + } + + /** + * Checks whether a string is a valid domain: a hostname or bare IP address + * (IPv4 or IPv6, unbracketed), optionally followed by a port number. + * Brackets are not supported, so an IPv6 address can't carry a port. + * + * @since 7.2.0 + * + * @param string $domain The domain to check. + * @return bool True if `$domain` is a valid domain, otherwise false. + */ + private function is_valid_domain( $domain ) { + if ( ! is_string( $domain ) || '' === $domain ) { + return false; + } + + // Bare IPv4 or IPv6 address, no port. + if ( $this->is_ip_address_candidate( $domain ) && rest_is_ip_address( $domain ) ) { + return true; + } + + // hostname[:port] or ipv4[:port]. A second colon means this isn't a + // single port suffix but an (invalid, since it failed the check above) + // unbracketed IPv6 address, so only split off a port when there's exactly one colon. + $host = $domain; + $port = null; + + if ( 1 === substr_count( $domain, ':' ) ) { + $colon_pos = strpos( $domain, ':' ); + $host = substr( $domain, 0, $colon_pos ); + $port = substr( $domain, $colon_pos + 1 ); + } + + if ( null !== $port && ! $this->is_valid_port( $port ) ) { + return false; + } + + if ( $this->is_ip_address_candidate( $host ) && rest_is_ip_address( $host ) ) { + return true; + } + + return 1 === preg_match( + '/^(?!-)[A-Za-z0-9-]{1,63}(?= 1 && (int) $port <= 65535; + } + + /** + * Checks whether a string is made up only of characters that can appear + * in an IPv4 or IPv6 (including IPv4-mapped) address, i.e. it's safe to + * pass to rest_is_ip_address(). That underlying check parses its input + * as hexadecimal, and PHP emits a warning for any other character, so + * this keeps arbitrary domain input from tripping that on the way in. + * + * @since 7.2.0 + * + * @param string $value The value to check. + * @return bool True if `$value` only contains valid IP address characters, otherwise false. + */ + private function is_ip_address_candidate( $value ) { + return 1 === preg_match( '/^[0-9A-Fa-f:.]+$/', $value ); + } + + /** + * Validates that the `path` parameter is a valid site path. + * + * @since 7.2.0 + * + * @param mixed $value Value of the `path` parameter. + * @param WP_REST_Request $request The current request. + * @param string $param The parameter name. + * @return true|WP_Error True if the value is valid, otherwise a WP_Error object. + */ + public function validate_path( $value, $request, $param ) { + $valid = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( $this->is_valid_path( $value ) ) { + return true; + } + + return new WP_Error( + 'rest_invalid_param', + __( 'path must be a valid site path, starting and ending with a forward slash.' ), + array( + 'status' => 400, + 'param' => $param, + ) + ); + } + + /** + * Checks whether a string is a valid site path: a forward slash, or a + * sequence of `/`-delimited segments each starting and ending with a + * forward slash, made up of characters allowed in a URL path segment + * (RFC 3986 `pchar`). + * + * @since 7.2.0 + * + * @param string $path The path to check. + * @return bool True if `$path` is a valid site path, otherwise false. + */ + private function is_valid_path( $path ) { + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + return 1 === preg_match( + '#^/(?:(?:[A-Za-z0-9._~!$&\'()*+,;=:@-]|%[0-9A-Fa-f]{2})+/)*$#', + $path + ); + } + + /** + * Checks that the domain and path for a prepared site are not already in use. + * + * When creating a site, the domain and path must not belong to any existing + * site. When updating a site, they may only belong to the site being updated. + * + * @since 7.2.0 + * + * @param array $prepared_site The prepared site data for `wp_insert_site()`. + * @param WP_REST_Request $request The current request. + * @return true|WP_Error True if the domain and path are available, WP_Error otherwise. + */ + protected function check_domain_is_available( $prepared_site, $request ) { + $id = (int) $request['id']; + $domain = isset( $prepared_site['domain'] ) ? $prepared_site['domain'] : ''; + $path = isset( $prepared_site['path'] ) ? $prepared_site['path'] : '/'; + $network_id = isset( $prepared_site['network_id'] ) ? $prepared_site['network_id'] : get_current_network_id(); + + if ( ! empty( $id ) ) { + // Updating a site: fall back to the current values for anything the request left out. + $current_site = $this->get_site( $id ); + + if ( is_wp_error( $current_site ) ) { + return $current_site; + } + + if ( ! isset( $prepared_site['domain'] ) ) { + $domain = $current_site->domain; + } + if ( ! isset( $prepared_site['path'] ) ) { + $path = $current_site->path; + } + if ( ! isset( $prepared_site['network_id'] ) ) { + $network_id = (int) $current_site->network_id; + } + } + + $existing_site_id = domain_exists( $domain, $path, $network_id ); + + // The domain and path may only belong to the site being updated. + if ( $existing_site_id && (int) $existing_site_id !== $id ) { + return new WP_Error( 'rest_site_taken', __( 'Sorry, that site already exists!' ), array( 'status' => 400 ) ); + } + + return true; + } + /** * Retrieves the site's schema, conforming to JSON Schema. * @@ -1010,18 +1218,25 @@ public function get_item_schema() { 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), + 'default' => get_current_network_id(), ), 'domain' => array( 'description' => __( 'Site domain.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '', + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_domain' ), + ), ), 'path' => array( 'description' => __( 'Site path.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '/', + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_path' ), + ), ), 'registered' => array( 'description' => __( 'When the site was registered, in the site\'s timezone.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 789eb40852f2c..805ae679f0e33 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -183,6 +183,128 @@ public function test_create_item() { $this->assertEquals( '/tempor/', $site->path ); } + /** + * @ticket 40365 + * @covers ::create_item + * @group ms-required + */ + public function test_create_item_rejects_an_existing_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/existing/', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/existing/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_taken', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::create_item + * @group ms-required + */ + public function test_create_item_allows_an_existing_domain_and_path_on_a_different_network() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/shared/', + ) + ); + + $network_id = self::factory()->network->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/shared/' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( WP_TESTS_DOMAIN, $data['domain'] ); + $this->assertEquals( '/shared/', $data['path'] ); + $this->assertEquals( $network_id, $data['network'] ); + } + + /** + * @ticket 40365 + * @covers ::update_item + * @group ms-required + */ + public function test_update_item_rejects_another_sites_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/taken/', + ) + ); + $blog_id = self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/free/', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/taken/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_taken', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item + * @group ms-required + */ + public function test_update_item_allows_a_site_to_keep_its_own_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/keep/', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/keep/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/keep/', $data['path'] ); + $this->assertEquals( 1, $data['mature'] ); + } + /** * @ticket 40365 * @covers ::update_item @@ -955,6 +1077,207 @@ public function test_update_item_keeps_the_domain() { $this->assertEquals( 0, get_site( $blog_id )->public ); } + /** + * The `domain` parameter must be a valid hostname or IP address, + * optionally followed by a port, when creating a site. + * + * @ticket 40365 + * @covers ::create_item + * @group ms-required + * @dataProvider data_domain_validation + */ + public function test_create_item_domain_validation( $domain, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', $domain ); + $request->set_param( 'path', '/domain-validation/' ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 201, $response->get_status(), "domain '{$domain}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $this->normalize_domain_for_storage( $domain ), $data['domain'] ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + } + + /** + * The `domain` parameter must be a valid hostname or IP address, + * optionally followed by a port, when updating a site. + * + * @ticket 40365 + * @covers ::update_item + * @group ms-required + * @dataProvider data_domain_validation + */ + public function test_update_item_domain_validation( $domain, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/domain-validation-update/' ) ); + $orig_domain = get_site( $blog_id )->domain; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', $domain ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 200, $response->get_status(), "domain '{$domain}' should have been accepted." ); + + $data = $response->get_data(); + $expected = $this->normalize_domain_for_storage( $domain ); + $this->assertEquals( $expected, $data['domain'] ); + $this->assertEquals( $expected, get_site( $blog_id )->domain ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( $orig_domain, get_site( $blog_id )->domain ); + } + } + + /** + * Mirrors the character-stripping done by `wp_normalize_site_data()` so that + * the domain-validation tests assert against what actually ends up stored, + * e.g. bracketed IPv6 literals lose their `[` `]` at that (unrelated) layer. + * + * @param string $domain Domain as submitted to the endpoint. + * @return string Domain as it will be persisted. + */ + private function normalize_domain_for_storage( $domain ) { + return preg_replace( '/[^a-z0-9\-.:]+/i', '', $domain ); + } + + /** + * Data provider for the `domain` format validation tests. + * + * @return array + */ + public function data_domain_validation() { + return array( + 'plain hostname' => array( 'example.org', true ), + 'subdomain' => array( 'sub.example.org', true ), + 'hostname with port' => array( 'example.org:8080', true ), + // The shape produced by wp-admin/network/site-new.php for a subdomain install + // when the network domain itself carries a port, e.g. "blogname.localhost:8889". + 'subdomain of a network domain with a port' => array( 'blogname.localhost:8889', true ), + 'ipv4' => array( '198.51.100.10', true ), + 'ipv4 with port' => array( '198.51.100.10:8080', true ), + 'bare ipv6 loopback, no port' => array( '::1', true ), + 'bare ipv6, no port' => array( '2001:db8::1', true ), + 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), + 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), + // Ambiguous: no brackets to separate a port, so the whole string is + // parsed as a literal (8-group, after :: expansion) IPv6 address. + 'ambiguous unbracketed ipv6 with trailing digits treated as address' => array( '2001:db8::1:8080', true ), + // Bracketed IPv6 is not supported at all: brackets aren't valid + // hostname or bare-IP characters, so any leading `[` is rejected outright. + 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), + 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), + 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), + 'empty brackets are rejected' => array( '[]', false ), + 'unbracketed ipv6-shaped string with a port-like trailing group is rejected' => array( '1:2:3:4:5:6:7:8:9', false ), + 'empty domain' => array( '', false ), + 'domain with a space' => array( 'example org', false ), + 'domain with a scheme' => array( 'http://example.org', false ), + 'leading hyphen label' => array( '-example.org', false ), + 'empty label' => array( 'example..org', false ), + 'port out of range' => array( 'example.org:99999', false ), + 'non numeric port' => array( 'example.org:abc', false ), + ); + } + + /** + * The `path` parameter must be a valid site path when creating a site. + * + * @ticket 40365 + * @covers ::create_item + * @group ms-required + * @dataProvider data_path_validation + */ + public function test_create_item_path_validation( $path, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + // A domain distinct from WP_TESTS_DOMAIN, so a root path doesn't collide with the network's main site. + $request->set_param( 'domain', 'path-validation.example.org' ); + $request->set_param( 'path', $path ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 201, $response->get_status(), "path '{$path}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $path, $data['path'] ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + } + + /** + * The `path` parameter must be a valid site path when updating a site. + * + * @ticket 40365 + * @covers ::update_item + * @group ms-required + * @dataProvider data_path_validation + */ + public function test_update_item_path_validation( $path, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + // A domain distinct from WP_TESTS_DOMAIN, so a root path doesn't collide with the network's main site. + $blog_id = self::factory()->blog->create( + array( + 'domain' => 'path-validation-update.example.org', + 'path' => '/path-validation-update/', + ) + ); + $orig_path = get_site( $blog_id )->path; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', $path ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 200, $response->get_status(), "path '{$path}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $path, $data['path'] ); + $this->assertEquals( $path, get_site( $blog_id )->path ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( $orig_path, get_site( $blog_id )->path ); + } + } + + /** + * Data provider for the `path` format validation tests. + * + * @return array + */ + public function data_path_validation() { + return array( + 'root path' => array( '/', true ), + 'single segment' => array( '/tempor/', true ), + 'nested segments' => array( '/parent/child/', true ), + 'segment with apostrophe' => array( "/o'brien/", true ), + 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), + 'percent encoded segment' => array( '/percent%20encoded/', true ), + 'empty path' => array( '', false ), + 'no leading slash' => array( 'no-leading-slash/', false ), + 'no trailing slash' => array( '/no-trailing-slash', false ), + 'double slash' => array( '//double-slash//', false ), + 'path with a space' => array( '/with space/', false ), + 'path with a query string' => array( '/with?query/', false ), + 'path with a fragment' => array( '/with#fragment/', false ), + 'path with a double quote' => array( '/with"quote/', false ), + ); + } + /** * Site meta is exposed through the endpoint. *