diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 4ec8f119f36e0..c3bc4f6eae9db 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -249,7 +249,7 @@ function rest_api_default_filters() { } // Default serving. - add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' ); + add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 4 ); add_filter( 'rest_post_dispatch', 'rest_send_allow_header', 10, 3 ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); @@ -808,11 +808,15 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * Sends Cross-Origin Resource Sharing headers with API requests. * * @since 4.4.0 + * @since 7.2.0 Added the `$result`, `$request`, and `$server` parameters and the `rest_allowed_cors_methods` filter. * - * @param mixed $value Response data. + * @param mixed $value Response data. + * @param WP_HTTP_Response|null $result Optional. Result to send to the client. Default null. + * @param WP_REST_Request|null $request Optional. The request in context. Default null. + * @param WP_REST_Server|null $server Optional. The server instance. Default null. * @return mixed Response data. */ -function rest_send_cors_headers( $value ) { +function rest_send_cors_headers( $value, $result = null, $request = null, $server = null ) { $origin = get_http_origin(); if ( $origin ) { @@ -820,10 +824,93 @@ function rest_send_cors_headers( $value ) { if ( 'null' !== $origin ) { $origin = sanitize_url( $origin ); } - header( 'Access-Control-Allow-Origin: ' . $origin ); - header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' ); - header( 'Access-Control-Allow-Credentials: true' ); - header( 'Vary: Origin', false ); + + /* + * Send through the server when serving a REST request. A direct call without one + * sends the headers itself, so that it does not set up the REST server. + */ + if ( ! $server instanceof WP_REST_Server ) { + $server = null; + } + $send_header = static function ( string $key, string $header_value ) use ( $server ): void { + if ( $server ) { + $server->send_header( $key, $header_value ); + } elseif ( ! headers_sent() ) { + header( "{$key}: {$header_value}" ); + } + }; + + $send_header( 'Access-Control-Allow-Origin', $origin ); + + /* + * The response's own headers have already been sent, and header() replaces them, + * so seed the list from any value the response set rather than discarding it. + */ + $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); + $response_headers = array_change_key_case( $response_headers ); + + /** + * Parses a list of methods, keeping only syntactically valid method tokens (RFC 9110, section 9.1). + * + * @phpstan-var Closure(mixed): list $parse_methods + */ + $parse_methods = static function ( $methods ): array { + if ( is_array( $methods ) ) { + // Join the entries so that each one is split like a string value, as in array( 'GET, POST' ). + $methods = implode( ',', array_filter( $methods, 'is_string' ) ); + } elseif ( ! is_string( $methods ) ) { + return array(); + } + $methods = preg_grep( '/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/D', array_unique( wp_parse_list( $methods ) ) ); + return is_array( $methods ) ? array_values( $methods ) : array(); + }; + + // Fall back to the default list when the response names no valid methods. + $allow_methods = $parse_methods( $response_headers['access-control-allow-methods'] ?? null ); + if ( empty( $allow_methods ) ) { + $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); + } + + /** + * Filters the list of HTTP methods advertised to REST API CORS requests. + * + * The allowed methods are passed to the browser to specify which methods + * may be used in a cross-origin request to the REST API. The list is a + * fixed default and is not derived from the registered routes, so a site + * serving routes that accept other methods can advertise them here. + * + * If the response already set its own `Access-Control-Allow-Methods` header + * naming valid methods, `$allow_methods` is those methods instead of the default list. + * Entries that are not valid method tokens are dropped, and an empty list + * omits the header. A value that is neither an array nor a string is ignored. + * + * @since 7.2.0 + * + * @param string[] $allow_methods The list of HTTP methods to allow. + * @param WP_REST_Request|null $request The request in context, if available. + */ + $filtered_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request instanceof WP_REST_Request ? $request : null ); + + // Keep the unfiltered list for a callback that does not return one, rather than omitting the header. + if ( is_array( $filtered_methods ) || is_string( $filtered_methods ) ) { + $allow_methods = $parse_methods( $filtered_methods ); + } + + if ( ! empty( $allow_methods ) ) { + $send_header( 'Access-Control-Allow-Methods', implode( ', ', $allow_methods ) ); + } elseif ( $server ) { + // Remove a value the response already sent. + $server->remove_header( 'Access-Control-Allow-Methods' ); + } elseif ( ! headers_sent() ) { + header_remove( 'Access-Control-Allow-Methods' ); + } + + $send_header( 'Access-Control-Allow-Credentials', 'true' ); + + // WP_REST_Server::send_header() replaces a header, and Vary must be appended to. + if ( ! headers_sent() ) { + header( 'Vary: Origin', false ); + } } elseif ( ! headers_sent() && 'GET' === $_SERVER['REQUEST_METHOD'] && ! is_user_logged_in() ) { header( 'Vary: Origin', false ); } diff --git a/tests/phpunit/includes/spy-rest-server.php b/tests/phpunit/includes/spy-rest-server.php index a714fc2d2e912..08650a37a7710 100644 --- a/tests/phpunit/includes/spy-rest-server.php +++ b/tests/phpunit/includes/spy-rest-server.php @@ -2,6 +2,7 @@ class Spy_REST_Server extends WP_REST_Server { + /** @var array */ public $sent_headers = array(); public $sent_body = ''; public $last_request = null; diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php new file mode 100644 index 0000000000000..0a696f32c0573 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -0,0 +1,491 @@ + + */ + private $original_server_values = array(); + + public function set_up() { + parent::set_up(); + + foreach ( array( 'HTTP_ORIGIN', 'REQUEST_METHOD' ) as $key ) { + $this->original_server_values[ $key ] = $_SERVER[ $key ] ?? null; + } + + // The CORS headers are only sent when the request carries an Origin. + $_SERVER['HTTP_ORIGIN'] = 'http://example.org'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Create a spy server to capture headers. + $GLOBALS['wp_rest_server'] = new Spy_REST_Server(); + + /** This action is documented in wp-includes/rest-api.php */ + do_action( 'rest_api_init', $GLOBALS['wp_rest_server'] ); + } + + public function tear_down() { + unset( $_GET['_envelope'] ); + foreach ( $this->original_server_values as $key => $value ) { + if ( null === $value ) { + unset( $_SERVER[ $key ] ); + } else { + $_SERVER[ $key ] = $value; + } + } + $GLOBALS['wp_rest_server'] = null; + + parent::tear_down(); + } + + /** + * Sends the CORS headers for a response to `/wp/v2/types`. + * + * @param array $response_headers Headers to set on the response. + */ + private function send_cors_headers( array $response_headers = array() ): void { + $response = new WP_REST_Response( array() ); + $response->set_headers( $response_headers ); + + rest_send_cors_headers( false, $response, new WP_REST_Request( 'GET', '/wp/v2/types' ), $this->get_rest_server() ); + } + + /** + * A response that sets no Access-Control-Allow-Methods header is sent the default list. + * + * @ticket 46992 + */ + public function test_default_list_is_sent() { + $this->send_cors_headers(); + + $this->assertSame( self::DEFAULT_ALLOW_METHODS, $this->get_rest_server()->sent_headers['Access-Control-Allow-Methods'] ); + } + + /** + * No method list is sent when the request has no Origin. + * + * @ticket 46992 + */ + public function test_no_list_is_sent_without_an_origin() { + unset( $_SERVER['HTTP_ORIGIN'] ); + + $this->send_cors_headers(); + + $this->assertArrayNotHasKey( 'Access-Control-Allow-Methods', $this->get_rest_server()->sent_headers ); + } + + /** + * An Access-Control-Allow-Methods header set on the response is passed through. + * + * rest_send_cors_headers() runs on 'rest_pre_serve_request', after the response's + * own headers have been sent, so sending the default list unconditionally replaced + * any value the response supplied. + * + * @ticket 46992 + */ + public function test_response_value_is_passed_through() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'OPTIONS, GET' ) ); + + $this->assertSame( 'OPTIONS, GET', $this->get_rest_server()->sent_headers['Access-Control-Allow-Methods'] ); + } + + /** + * The response header is matched without regard to case. + * + * @ticket 46992 + */ + public function test_response_value_is_matched_case_insensitively() { + $this->send_cors_headers( array( 'access-control-allow-methods' => 'OPTIONS, GET' ) ); + + $this->assertSame( 'OPTIONS, GET', $this->get_rest_server()->sent_headers['Access-Control-Allow-Methods'] ); + } + + /** + * A response value may name methods outside the default list. + * + * @ticket 46992 + */ + public function test_response_value_can_add_methods_beyond_the_default() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'OPTIONS, GET, PROPFIND' ) ); + + $this->assertSame( 'OPTIONS, GET, PROPFIND', $this->get_rest_server()->sent_headers['Access-Control-Allow-Methods'] ); + } + + /** + * Entries in a response value that are not valid method tokens are dropped. + * + * @ticket 46992 + */ + public function test_response_value_drops_invalid_method_tokens() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'GET, P@TCH,